From 15a3d0452cb2f9b95daa99f08955028ad60a0b1d Mon Sep 17 00:00:00 2001 From: Nick Zelei <2420177+nickzelei@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:21:56 -0800 Subject: [PATCH] NEOS-1696, NEOS-1682: Adds bulk apply subsets, Adds mssql subset validation (#3101) --- .../hooks/components/JobConfigSqlForm.tsx | 43 +---- .../[id]/subsets/components/SubsetCard.tsx | 166 +++++++++++++----- .../[account]/new/job/job-form-validations.ts | 2 +- .../(mgmt)/[account]/new/job/subset/page.tsx | 166 +++++++++++++----- .../jobs/subsets/SubsetTable/Columns.tsx | 30 +++- .../jobs/subsets/SubsetTable/SubsetTable.tsx | 38 ++-- .../SubsetTable/SubsetTableToolbar.tsx | 48 +++++ .../jobs/subsets/{ => edit}/EditItem.tsx | 161 ++++------------- .../subsets/{ => edit}/EditItemDialog.tsx | 0 .../jobs/subsets/edit/EditItems.tsx | 64 +++++++ .../jobs/subsets/edit/WhereEditor.tsx | 129 ++++++++++++++ .../subsets/edit/useOnBulkEditItemSave.tsx | 73 ++++++++ .../jobs/subsets/edit/useOnEditItemSave.tsx | 55 ++++++ .../apps/web/components/jobs/subsets/utils.ts | 19 ++ .../web/libs/hooks/monaco/useMonacoOnMount.ts | 22 +++ .../web/libs/hooks/monaco/useMonacoResizer.ts | 36 ++++ .../web/libs/hooks/monaco/useMonacoTheme.ts | 13 ++ 17 files changed, 797 insertions(+), 268 deletions(-) create mode 100644 frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTableToolbar.tsx rename frontend/apps/web/components/jobs/subsets/{ => edit}/EditItem.tsx (59%) rename frontend/apps/web/components/jobs/subsets/{ => edit}/EditItemDialog.tsx (100%) create mode 100644 frontend/apps/web/components/jobs/subsets/edit/EditItems.tsx create mode 100644 frontend/apps/web/components/jobs/subsets/edit/WhereEditor.tsx create mode 100644 frontend/apps/web/components/jobs/subsets/edit/useOnBulkEditItemSave.tsx create mode 100644 frontend/apps/web/components/jobs/subsets/edit/useOnEditItemSave.tsx create mode 100644 frontend/apps/web/libs/hooks/monaco/useMonacoOnMount.ts create mode 100644 frontend/apps/web/libs/hooks/monaco/useMonacoResizer.ts create mode 100644 frontend/apps/web/libs/hooks/monaco/useMonacoTheme.ts diff --git a/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/hooks/components/JobConfigSqlForm.tsx b/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/hooks/components/JobConfigSqlForm.tsx index b71466f3d9..1de695511d 100644 --- a/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/hooks/components/JobConfigSqlForm.tsx +++ b/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/hooks/components/JobConfigSqlForm.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useMemo } from 'react'; +import { ReactElement } from 'react'; import ConnectionSelectContent from '@/app/(mgmt)/[account]/new/job/connect/ConnectionSelectContent'; import FormErrorMessage from '@/components/FormErrorMessage'; @@ -9,13 +9,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import useMonacoResizer from '@/libs/hooks/monaco/useMonacoResizer'; +import useMonacoTheme from '@/libs/hooks/monaco/useMonacoTheme'; import { splitConnections } from '@/libs/utils'; import { Editor } from '@monaco-editor/react'; import { Connection } from '@neosync/sdk'; import { editor } from 'monaco-editor'; -import { useTheme } from 'next-themes'; -import { useResizeDetector } from 'react-resize-detector'; -import { OnRefChangeType } from 'react-resize-detector/build/types/types'; import FormHeader from './FormHeader'; import { JobHookSqlFormValues, SqlTimingFormValue } from './validation'; @@ -97,11 +96,7 @@ const editorOptions: editor.IStandaloneEditorConstructionOptions = { function EditSqlQuery(props: EditSqlQueryProps): ReactElement { const { query, setQuery } = props; - const { resolvedTheme } = useTheme(); - const theme = useMemo( - () => (resolvedTheme === 'dark' ? 'vs-dark' : 'cobalt'), - [resolvedTheme] - ); + const theme = useMonacoTheme(); const { ref, width: editorWidth } = useMonacoResizer(); return ( @@ -126,36 +121,6 @@ function EditSqlQuery(props: EditSqlQueryProps): ReactElement { ); } -// Offset is important here as without it, things get pretty strange I believe due to the container -// Lower offsets cause the resize to happen at a glacial pace, and without one, not at all. -const WIDTH_OFFSET = 10; - -function useMonacoResizer(): { - ref: OnRefChangeType; - width: string; -} { - const { ref, width } = useResizeDetector({ - handleHeight: false, - handleWidth: true, - refreshMode: 'debounce', - refreshRate: 10, - skipOnMount: false, - }); - - const editorWidth = useMemo( - () => - width != null && width > WIDTH_OFFSET - ? `${width - WIDTH_OFFSET}px` - : '100%', - [width] - ); - - return { - ref, - width: editorWidth, - }; -} - interface SelectConnectionsProps { jobConnections: Connection[]; diff --git a/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/subsets/components/SubsetCard.tsx b/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/subsets/components/SubsetCard.tsx index f5994573ba..572efd8035 100644 --- a/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/subsets/components/SubsetCard.tsx +++ b/frontend/apps/web/app/(mgmt)/[account]/jobs/[id]/subsets/components/SubsetCard.tsx @@ -4,8 +4,13 @@ import { } from '@/app/(mgmt)/[account]/connections/util'; import { SubsetFormValues } from '@/app/(mgmt)/[account]/new/job/job-form-validations'; import SubsetOptionsForm from '@/components/jobs/Form/SubsetOptionsForm'; -import EditItem from '@/components/jobs/subsets/EditItem'; -import EditItemDialog from '@/components/jobs/subsets/EditItemDialog'; +import EditItem from '@/components/jobs/subsets/edit/EditItem'; +import EditItemDialog from '@/components/jobs/subsets/edit/EditItemDialog'; +import EditItems from '@/components/jobs/subsets/edit/EditItems'; +import useOnBulkEditItemSave, { + BulkEditItem, +} from '@/components/jobs/subsets/edit/useOnBulkEditItemSave'; +import useOnEditItemSave from '@/components/jobs/subsets/edit/useOnEditItemSave'; import { SUBSET_TABLE_COLUMNS, SubsetTableRow, @@ -14,6 +19,7 @@ import SubsetTable from '@/components/jobs/subsets/SubsetTable/SubsetTable'; import { buildRowKey, buildTableRowData, + getBulkColumnsForSqlAutocomplete, getColumnsForSqlAutocomplete, isValidSubsetType, } from '@/components/jobs/subsets/utils'; @@ -52,6 +58,7 @@ interface Props { export default function SubsetCard(props: Props): ReactElement { const { jobId } = props; const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isBulkEditDialogOpen, setIsBulkEditDialogOpen] = useState(false); const { data, isLoading: isJobLoading } = useQuery( JobService.method.getJob, @@ -105,6 +112,8 @@ export default function SubsetCard(props: Props): ReactElement { control: form.control, name: 'subsets', }); + const [itemToEdit, setItemToEdit] = useState(); + const [bulkItemEdit, setBulkItemEdit] = useState(); const tableRowData = useMemo(() => { return buildTableRowData( @@ -113,7 +122,39 @@ export default function SubsetCard(props: Props): ReactElement { formSubsets ); }, [data?.job?.mappings, rootTables, formSubsets]); - const [itemToEdit, setItemToEdit] = useState(); + + const { onClick: onEditItemSave } = useOnEditItemSave({ + item: itemToEdit, + getSubsets: () => formSubsets, + appendSubsets: addSubsetsFormValues, + triggerUpdate: () => { + form.trigger(); + setIsDialogOpen(false); + setItemToEdit(undefined); + }, + updateSubset: (idx, subset) => { + updateSubsetsFormValues(idx, subset); + }, + }); + + const { onClick: onBulkEditItemSave } = useOnBulkEditItemSave({ + bulkEditItem: bulkItemEdit, + getSubsets: () => formSubsets, + setSubsets: () => { + form.setValue('subsets', formSubsets, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: false, + }); + }, + triggerUpdate: () => { + form.trigger(); + setIsBulkEditDialogOpen(false); + setBulkItemEdit(undefined); + }, + getTableRowData: (key) => tableRowData[key], + appendSubsets: addSubsetsFormValues, + }); const formValuesMap = new Map( formValues.subsets.map((ss) => [buildRowKey(ss.schema, ss.table), ss]) @@ -127,6 +168,25 @@ export default function SubsetCard(props: Props): ReactElement { ); }, [data?.job?.mappings, itemToEdit?.schema, itemToEdit?.table]); + const bulkSqlAutocompleteColumns = useMemo(() => { + if (!bulkItemEdit) { + return []; + } + return getBulkColumnsForSqlAutocomplete( + data?.job?.mappings ?? [], + bulkItemEdit.rowKeys.map((key) => { + const td = tableRowData[key]; + if (!td) { + return { schema: '', table: '' }; + } + return { + schema: td.schema, + table: td.table, + }; + }) ?? [] + ); + }, [data?.job?.mappings, bulkItemEdit?.rowKeys, tableRowData]); + if (isJobLoading) { return (
@@ -220,6 +280,39 @@ export default function SubsetCard(props: Props): ReactElement { } } + function onEdit(_rowIdx: number, schema: string, table: string): void { + setIsDialogOpen(true); + const key = buildRowKey(schema, table); + if (tableRowData[key]) { + setItemToEdit({ + ...tableRowData[key], + }); + } + } + + function onBulkEdit( + data: SubsetTableRow[], + onClearSelection: () => void + ): void { + // todo: if only one item is selected, just go through the single item flow + if (data.length === 0) { + return; + } + if (data.length === 1) { + onEdit(0, data[0].schema, data[0].table); + return; + } + const firstWhereClauseIdx = data.findIndex((item) => !!item.where); + setBulkItemEdit({ + rowKeys: data.map((item) => buildRowKey(item.schema, item.table)), + item: { + where: firstWhereClauseIdx >= 0 ? data[firstWhereClauseIdx].where : '', + }, + onClearSelection, + }); + setIsBulkEditDialogOpen(true); + } + return (
@@ -232,15 +325,8 @@ export default function SubsetCard(props: Props): ReactElement { { - setIsDialogOpen(true); - const key = buildRowKey(schema, table); - if (tableRowData[key]) { - setItemToEdit({ - ...tableRowData[key], - }); - } - }} + onEdit={onEdit} + onBulkEdit={onBulkEdit} hasLocalChange={hasLocalChange} onReset={onLocalRowReset} /> @@ -259,39 +345,37 @@ export default function SubsetCard(props: Props): ReactElement { setIsDialogOpen(false); }} columns={sqlAutocompleteColumns} - onSave={() => { - if (!itemToEdit) { - return; - } - const key = buildRowKey( - itemToEdit.schema, - itemToEdit.table - ); - const idx = form - .getValues() - .subsets.findIndex( - (item) => buildRowKey(item.schema, item.table) === key - ); - if (idx >= 0) { - updateSubsetsFormValues(idx, { - schema: itemToEdit.schema, - table: itemToEdit.table, - whereClause: itemToEdit.where, - }); - } else { - addSubsetsFormValues({ - schema: itemToEdit.schema, - table: itemToEdit.table, - whereClause: itemToEdit.where, - }); - } - setItemToEdit(undefined); - setIsDialogOpen(false); - }} + onSave={onEditItemSave} connectionType={connectionType} /> } /> + { + setBulkItemEdit((prev) => { + if (!prev) { + return undefined; + } + return { + ...prev, + item, + }; + }); + }} + onCancel={() => { + setBulkItemEdit(undefined); + setIsBulkEditDialogOpen(false); + }} + columns={bulkSqlAutocompleteColumns} + onSave={onBulkEditItemSave} + /> + } + />
); }, - size: 250, }); const actionsColumn = columnHelper.display({ @@ -107,6 +134,7 @@ function getColumns(): ColumnDef[] { }); return [ + checkboxColumn, schemaColumn, tableColumn, isRootTableColumn, diff --git a/frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTable.tsx b/frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTable.tsx index 8b1417f222..d839d2ab09 100644 --- a/frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTable.tsx +++ b/frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTable.tsx @@ -1,7 +1,4 @@ -import ButtonText from '@/components/ButtonText'; import FastTable from '@/components/FastTable/FastTable'; -import { Button } from '@/components/ui/button'; -import { Cross2Icon } from '@radix-ui/react-icons'; import { ColumnDef, getCoreRowModel, @@ -11,6 +8,7 @@ import { useReactTable, } from '@tanstack/react-table'; import { ReactElement } from 'react'; +import { SubsetTableToolbar } from './SubsetTableToolbar'; declare module '@tanstack/react-table' { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -29,17 +27,18 @@ interface Props { onEdit(rowIndex: number, schema: string, table: string): void; onReset(rowIndex: number, schema: string, table: string): void; hasLocalChange(rowIndex: number, schema: string, table: string): boolean; + onBulkEdit(data: TData[], onClearSelection: () => void): void; } export default function SubsetTable( props: Props ): ReactElement { - const { data, columns, onEdit, onReset, hasLocalChange } = props; + const { data, columns, onEdit, onReset, hasLocalChange, onBulkEdit } = props; const table = useReactTable({ data, columns, - enableRowSelection: false, + enableRowSelection: true, initialState: {}, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), @@ -55,22 +54,19 @@ export default function SubsetTable( return (
-
-
- -
-
+ table.resetColumnFilters()} + isBulkEditButtonDisabled={ + Object.keys(table.getState().rowSelection).length <= 1 + } + onBulkEditClick={() => { + const selectedRows = table + .getSelectedRowModel() + .rows.map((row) => row.original); + onBulkEdit(selectedRows, () => table.resetRowSelection()); + }} + /> 33} rowOverscan={50} />
); diff --git a/frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTableToolbar.tsx b/frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTableToolbar.tsx new file mode 100644 index 0000000000..a1a6775c07 --- /dev/null +++ b/frontend/apps/web/components/jobs/subsets/SubsetTable/SubsetTableToolbar.tsx @@ -0,0 +1,48 @@ +import ButtonText from '@/components/ButtonText'; +import { Button } from '@/components/ui/button'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import { ReactElement } from 'react'; + +interface Props { + isFilterButtonDisabled: boolean; + onClearFilters(): void; + + isBulkEditButtonDisabled: boolean; + onBulkEditClick(): void; +} +export function SubsetTableToolbar(props: Props): ReactElement { + const { + onClearFilters, + isFilterButtonDisabled, + isBulkEditButtonDisabled, + onBulkEditClick, + } = props; + + return ( +
+
+ + +
+
+ ); +} diff --git a/frontend/apps/web/components/jobs/subsets/EditItem.tsx b/frontend/apps/web/components/jobs/subsets/edit/EditItem.tsx similarity index 59% rename from frontend/apps/web/components/jobs/subsets/EditItem.tsx rename to frontend/apps/web/components/jobs/subsets/edit/EditItem.tsx index e2804ceddc..8d40ad3e3c 100644 --- a/frontend/apps/web/components/jobs/subsets/EditItem.tsx +++ b/frontend/apps/web/components/jobs/subsets/edit/EditItem.tsx @@ -1,4 +1,3 @@ -import { ConnectionConfigCase } from '@/app/(mgmt)/[account]/connections/util'; import ButtonText from '@/components/ButtonText'; import Spinner from '@/components/Spinner'; import { Badge } from '@/components/ui/badge'; @@ -13,7 +12,6 @@ import { cn } from '@/libs/utils'; import { getErrorMessage } from '@/util/util'; import { create } from '@bufbuild/protobuf'; import { useMutation } from '@connectrpc/connect-query'; -import { Editor, useMonaco } from '@monaco-editor/react'; import { CheckSqlQueryResponse, CheckSqlQueryResponseSchema, @@ -21,17 +19,16 @@ import { ConnectionService, GetTableRowCountResponse, } from '@neosync/sdk'; -import { editor } from 'monaco-editor'; -import { useTheme } from 'next-themes'; -import { ReactElement, useEffect, useRef, useState } from 'react'; -import ValidateQueryErrorAlert from './SubsetErrorAlert'; -import { SubsetTableRow } from './SubsetTable/Columns'; -import ValidateQueryBadge from './ValidateQueryBadge'; +import { ReactElement, useEffect, useMemo, useState } from 'react'; +import ValidateQueryErrorAlert from '../SubsetErrorAlert'; +import { SubsetTableRow } from '../SubsetTable/Columns'; +import ValidateQueryBadge from '../ValidateQueryBadge'; import { isSubsetRowCountSupported, isSubsetValidationSupported, ValidSubsetConnectionType, -} from './utils'; +} from '../utils'; +import WhereEditor from './WhereEditor'; interface Props { item?: SubsetTableRow; @@ -59,61 +56,16 @@ export default function EditItem(props: Props): ReactElement { GetTableRowCountResponse | undefined >(); const [calculatingRowCount, setCalculatingRowCount] = useState(false); - const { resolvedTheme } = useTheme(); - const editorRef = useRef(null); const [rowCountError, setRowCountError] = useState(); - const monaco = useMonaco(); - - const showRowCountButton = isSubsetRowCountSupported(connectionType); - const showValidateButton = isSubsetValidationSupported(connectionType); - - useEffect(() => { - if (monaco) { - const provider = monaco.languages.registerCompletionItemProvider('sql', { - triggerCharacters: [' ', '.'], // Trigger autocomplete on space and dot - - provideCompletionItems: (model, position) => { - const textUntilPosition = model.getValueInRange({ - startLineNumber: 1, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }); - - const columnSet = new Set(columns); - - // Check if the last character or word should trigger the auto-complete - if (!shouldTriggerAutocomplete(textUntilPosition)) { - return { suggestions: [] }; - } - - const word = model.getWordUntilPosition(position); - - const range = { - startLineNumber: position.lineNumber, - startColumn: word.startColumn, - endLineNumber: position.lineNumber, - endColumn: word.endColumn, - }; - - const suggestions = Array.from(columnSet).map((name) => ({ - label: name, // would be nice if we could add the type here as well? - kind: monaco.languages.CompletionItemKind.Field, - insertText: name, - range: range, - })); - - return { suggestions: suggestions }; - }, - }); - /* disposes of the instance if the component re-renders, otherwise the auto-compelte list just keeps appending the column names to the auto-complete, so you get liek 20 'address' entries for ex. then it re-renders and then it goes to 30 'address' entries - */ - return () => { - provider.dispose(); - }; - } - }, [monaco, columns]); + const showRowCountButton = useMemo( + () => isSubsetRowCountSupported(connectionType), + [connectionType] + ); + const showValidateButton = useMemo( + () => isSubsetValidationSupported(connectionType), + [connectionType] + ); function onWhereChange(value: string): void { if (!item) { @@ -135,14 +87,22 @@ export default function EditItem(props: Props): ReactElement { ); async function onValidate(): Promise { - if (connectionType === 'pgConfig' || connectionType === 'mysqlConfig') { - const pgString = `select * from "${item?.schema}"."${item?.table}" WHERE ${item?.where};`; - const mysqlString = `select * from \`${item?.schema}\`.\`${item?.table}\` WHERE ${item?.where};`; + if ( + connectionType === 'pgConfig' || + connectionType === 'mysqlConfig' || + connectionType === 'mssqlConfig' + ) { + let queryString = ''; + if (connectionType === 'pgConfig' || connectionType === 'mssqlConfig') { + queryString = `select * from "${item?.schema}"."${item?.table}" WHERE ${item?.where};`; + } else if (connectionType === 'mysqlConfig') { + queryString = `select * from \`${item?.schema}\`.\`${item?.table}\` WHERE ${item?.where};`; + } try { const resp = await validateSql({ id: connectionId, - query: connectionType === 'mysqlConfig' ? mysqlString : pgString, + query: queryString, }); setValidateResp(resp); } catch (err) { @@ -188,26 +148,6 @@ export default function EditItem(props: Props): ReactElement { onSave(); } - // options for the sql editor - const editorOptions = { - minimap: { enabled: false }, - roundedSelection: false, - scrollBeyondLastLine: false, - readOnly: !item, - renderLineHighlight: 'none' as const, - overviewRulerBorder: false, - overviewRulerLanes: 0, - lineNumbers: !item || item.where == '' ? ('off' as const) : ('on' as const), - }; - - const constructWhere = (value: string) => { - if (item?.where && !value.startsWith('WHERE ')) { - return `WHERE ${value}`; - } else if (!item?.where) { - return ''; - } - }; - return (
@@ -244,21 +184,11 @@ export default function EditItem(props: Props): ReactElement {
-
- onWhereChange(e?.replace('WHERE ', '') ?? '')} - options={editorOptions} - onMount={(editor) => { - editorRef.current = editor; - editor.focus(); - }} - /> -
+ { - const editor = editorRef.current; - editor?.setValue(''); onSaveClick(); }} > @@ -361,25 +289,10 @@ export default function EditItem(props: Props): ReactElement { ); } -function showSchema(connectionType: ConnectionConfigCase | null): boolean { - return connectionType === 'pgConfig' || connectionType === 'mysqlConfig'; -} - -function shouldTriggerAutocomplete(text: string): boolean { - const trimmedText = text.trim(); - const textSplit = trimmedText.split(/\s+/); - const lastSignificantWord = trimmedText.split(/\s+/).pop()?.toUpperCase(); - const triggerKeywords = ['SELECT', 'WHERE', 'AND', 'OR', 'FROM']; - - if (textSplit.length == 2 && textSplit[0].toUpperCase() == 'WHERE') { - /* since we pre-pend the 'WHERE', we want the autocomplete to show up for the first letter typed - which would come through as 'WHERE a' if the user just typed the letter 'a' - so the when we split that text, we check if the length is 2 (as a way of checking if the user has only typed one letter or is still on the first word) and if it is and the first word is 'WHERE' which it should be since we pre-pend it, then show the auto-complete */ - return true; - } else { - return ( - triggerKeywords.includes(lastSignificantWord || '') || - triggerKeywords.some((keyword) => trimmedText.endsWith(keyword + ' ')) - ); - } +function showSchema(connectionType: ValidSubsetConnectionType | null): boolean { + return ( + connectionType === 'pgConfig' || + connectionType === 'mysqlConfig' || + connectionType === 'mssqlConfig' + ); } diff --git a/frontend/apps/web/components/jobs/subsets/EditItemDialog.tsx b/frontend/apps/web/components/jobs/subsets/edit/EditItemDialog.tsx similarity index 100% rename from frontend/apps/web/components/jobs/subsets/EditItemDialog.tsx rename to frontend/apps/web/components/jobs/subsets/edit/EditItemDialog.tsx diff --git a/frontend/apps/web/components/jobs/subsets/edit/EditItems.tsx b/frontend/apps/web/components/jobs/subsets/edit/EditItems.tsx new file mode 100644 index 0000000000..59323c8802 --- /dev/null +++ b/frontend/apps/web/components/jobs/subsets/edit/EditItems.tsx @@ -0,0 +1,64 @@ +import ButtonText from '@/components/ButtonText'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { ReactElement } from 'react'; +import { SubsetTableRow } from '../SubsetTable/Columns'; +import WhereEditor from './WhereEditor'; + +interface Props { + item: Pick; + onItem(item: Pick): void; + onSave(): void; + onCancel(): void; + columns: string[]; +} + +export default function EditItems(props: Props): ReactElement { + const { item, onItem, onSave, onCancel, columns } = props; + + function onWhereChange(whereClause: string): void { + onItem({ where: whereClause }); + } + + return ( +
+ +
+ +
+ + + + + + +

+ Applies changes to table only, click Save below to fully + submit changes +

+
+
+
+
+
+
+ ); +} diff --git a/frontend/apps/web/components/jobs/subsets/edit/WhereEditor.tsx b/frontend/apps/web/components/jobs/subsets/edit/WhereEditor.tsx new file mode 100644 index 0000000000..18c4a79346 --- /dev/null +++ b/frontend/apps/web/components/jobs/subsets/edit/WhereEditor.tsx @@ -0,0 +1,129 @@ +import useMonacoOnMount from '@/libs/hooks/monaco/useMonacoOnMount'; +import useMonacoResizer from '@/libs/hooks/monaco/useMonacoResizer'; +import useMonacoTheme from '@/libs/hooks/monaco/useMonacoTheme'; +import { Editor, useMonaco } from '@monaco-editor/react'; +import { editor, IRange, languages } from 'monaco-editor'; +import { ReactElement, useEffect } from 'react'; + +interface Props { + whereClause: string; + onWhereChange(whereClause: string): void; + columns: string[]; +} + +// options for the sql editor +const BASE_EDITOR_OPTS: editor.IStandaloneEditorConstructionOptions = { + minimap: { enabled: false }, + roundedSelection: false, + scrollBeyondLastLine: false, + renderLineHighlight: 'none' as const, + overviewRulerBorder: false, + overviewRulerLanes: 0, + lineNumbers: 'on', +}; + +export default function WhereEditor(props: Props): ReactElement { + const { whereClause, onWhereChange, columns } = props; + + const theme = useMonacoTheme(); + const { ref, width: editorWidth } = useMonacoResizer(); + const { onMount } = useMonacoOnMount(); + useAutocomplete(columns); + + return ( +
+ onWhereChange(e?.replace('WHERE ', '') ?? '')} + options={BASE_EDITOR_OPTS} + onMount={onMount} + /> +
+ ); +} + +// enables auto complete of the columns in the where clause +function useAutocomplete(columns: string[]): void { + const monaco = useMonaco(); + useEffect(() => { + if (!monaco) { + return; + } + + const columnSet = new Set(columns); + + const provider = monaco.languages.registerCompletionItemProvider('sql', { + triggerCharacters: [' ', '.'], // Trigger autocomplete on space and dot + + provideCompletionItems(model, position) { + const textUntilPosition = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + // Check if the last character or word should trigger the auto-complete + if (!shouldTriggerAutocomplete(textUntilPosition)) { + return { suggestions: [] }; + } + + const word = model.getWordUntilPosition(position); + + const range: IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const suggestions: languages.CompletionItem[] = Array.from( + columnSet + ).map( + (name): languages.CompletionItem => ({ + label: name, // would be nice if we could add the type here as well? + kind: monaco.languages.CompletionItemKind.Field, + insertText: name, + range: range, + }) + ); + + return { suggestions: suggestions }; + }, + }); + /* disposes of the instance if the component re-renders, otherwise the auto-compelte list just keeps appending the column names to the auto-complete, so you get liek 20 'address' entries for ex. then it re-renders and then it goes to 30 'address' entries + */ + return () => provider.dispose(); + }, [monaco, columns]); +} + +function constructWhere(whereValue: string): string { + if (!whereValue) return ''; + return whereValue.startsWith('WHERE ') ? whereValue : `WHERE ${whereValue}`; +} + +function shouldTriggerAutocomplete(text: string): boolean { + const trimmedText = text.trim(); + const textSplit = trimmedText.split(/\s+/); + const lastSignificantWord = trimmedText.split(/\s+/).pop()?.toUpperCase(); + const triggerKeywords = ['SELECT', 'WHERE', 'AND', 'OR', 'FROM']; + + if (textSplit.length == 2 && textSplit[0].toUpperCase() == 'WHERE') { + /* since we pre-pend the 'WHERE', we want the autocomplete to show up for the first letter typed + which would come through as 'WHERE a' if the user just typed the letter 'a' + so the when we split that text, we check if the length is 2 (as a way of checking if the user has only typed one letter or is still on the first word) and if it is and the first word is 'WHERE' which it should be since we pre-pend it, then show the auto-complete */ + return true; + } else { + return ( + triggerKeywords.includes(lastSignificantWord || '') || + triggerKeywords.some((keyword) => trimmedText.endsWith(keyword + ' ')) + ); + } +} diff --git a/frontend/apps/web/components/jobs/subsets/edit/useOnBulkEditItemSave.tsx b/frontend/apps/web/components/jobs/subsets/edit/useOnBulkEditItemSave.tsx new file mode 100644 index 0000000000..61700674ea --- /dev/null +++ b/frontend/apps/web/components/jobs/subsets/edit/useOnBulkEditItemSave.tsx @@ -0,0 +1,73 @@ +import { SingleSubsetFormValue } from '@/app/(mgmt)/[account]/new/job/job-form-validations'; +import { SubsetTableRow } from '../SubsetTable/Columns'; +import { buildRowKey } from '../utils'; + +export interface BulkEditItem { + rowKeys: string[]; // the key of the rows being edited from the tableRowData variable + item: Pick; + onClearSelection(): void; +} + +interface Props { + bulkEditItem: BulkEditItem | undefined; // undefined handles the unselected state + + getSubsets(): SingleSubsetFormValue[]; + setSubsets(subsets: SingleSubsetFormValue[]): void; + appendSubsets(subsets: SingleSubsetFormValue[]): void; + triggerUpdate(): void; + getTableRowData(key: string): SubsetTableRow | undefined; +} + +interface UseOnBulkEditItemSaveResponse { + onClick(): void; +} + +export default function useOnBulkEditItemSave( + props: Props +): UseOnBulkEditItemSaveResponse { + const { + bulkEditItem, + getSubsets, + setSubsets, + triggerUpdate, + getTableRowData, + appendSubsets, + } = props; + + return { + onClick() { + if (!bulkEditItem) { + return; + } + const { rowKeys, onClearSelection, item } = bulkEditItem; + const subsets = getSubsets(); + const subsetsToEdit = new Map( + subsets.map((ss) => [buildRowKey(ss.schema, ss.table), ss]) + ); + const subsetsToAdd: SingleSubsetFormValue[] = []; + rowKeys.forEach((key) => { + const subset = subsetsToEdit.get(key); + if (subset) { + subset.whereClause = item.where; + } else { + const td = getTableRowData(key); + if (td) { + subsetsToAdd.push({ + schema: td.schema, + table: td.table, + whereClause: item.where, + }); + } + } + }); + setSubsets(subsets); + if (subsetsToAdd.length > 0) { + appendSubsets(subsetsToAdd); + } + setTimeout(() => { + triggerUpdate(); + onClearSelection(); + }, 0); + }, + }; +} diff --git a/frontend/apps/web/components/jobs/subsets/edit/useOnEditItemSave.tsx b/frontend/apps/web/components/jobs/subsets/edit/useOnEditItemSave.tsx new file mode 100644 index 0000000000..8a0b5a335b --- /dev/null +++ b/frontend/apps/web/components/jobs/subsets/edit/useOnEditItemSave.tsx @@ -0,0 +1,55 @@ +import { SingleSubsetFormValue } from '@/app/(mgmt)/[account]/new/job/job-form-validations'; +import { SubsetTableRow } from '../SubsetTable/Columns'; +import { buildRowKey } from '../utils'; + +interface Props { + item: SubsetTableRow | undefined; // undefined handles the unselected state + + getSubsets(): SingleSubsetFormValue[]; + appendSubsets(subsets: SingleSubsetFormValue[]): void; + triggerUpdate(): void; + updateSubset(idx: number, subset: SingleSubsetFormValue): void; +} + +interface UseOnEditItemSaveResponse { + onClick(): void; +} + +export default function useOnEditItemSave( + props: Props +): UseOnEditItemSaveResponse { + const { item, getSubsets, triggerUpdate, appendSubsets, updateSubset } = + props; + + return { + onClick() { + if (!item) { + return; + } + const key = buildRowKey(item.schema, item.table); + + const subsets = getSubsets(); + const existingSubsetIdx = subsets.findIndex( + (ss) => buildRowKey(ss.schema, ss.table) === key + ); + if (existingSubsetIdx >= 0) { + updateSubset(existingSubsetIdx, { + schema: item.schema, + table: item.table, + whereClause: item.where, + }); + } else { + appendSubsets([ + { + schema: item.schema, + table: item.table, + whereClause: item.where, + }, + ]); + } + setTimeout(() => { + triggerUpdate(); + }, 0); + }, + }; +} diff --git a/frontend/apps/web/components/jobs/subsets/utils.ts b/frontend/apps/web/components/jobs/subsets/utils.ts index a51433a9d3..a86db6b3fb 100644 --- a/frontend/apps/web/components/jobs/subsets/utils.ts +++ b/frontend/apps/web/components/jobs/subsets/utils.ts @@ -57,6 +57,25 @@ export function getColumnsForSqlAutocomplete( .map((row) => row.column); } +export function getBulkColumnsForSqlAutocomplete( + mappings: Pick[], + schemaTable: { schema: string; table: string }[] +): string[] { + if (!mappings) { + return []; + } + const schemaTableSet = new Set( + schemaTable.map((st) => buildRowKey(st.schema, st.table)) + ); + return Array.from( + new Set( + mappings + .filter((row) => schemaTableSet.has(buildRowKey(row.schema, row.table))) + .map((row) => row.column) + ) + ); +} + export function isJobSubsettable(job: Job): boolean { switch (job.source?.options?.config.case) { case 'postgres': diff --git a/frontend/apps/web/libs/hooks/monaco/useMonacoOnMount.ts b/frontend/apps/web/libs/hooks/monaco/useMonacoOnMount.ts new file mode 100644 index 0000000000..80659689a1 --- /dev/null +++ b/frontend/apps/web/libs/hooks/monaco/useMonacoOnMount.ts @@ -0,0 +1,22 @@ +import { editor } from 'monaco-editor'; + +interface UseMonacoOnMountReturn { + onMount(editor: editor.IStandaloneCodeEditor): void; +} + +// Standard on mount that auto focuses the editor and moves the cursor to the end of the document +// Only use this if you want the editor to be focused and the cursor to be at the end of the document when it comes into view +// Mostly useful in Dialogs if the editor is the main focus of the dialog +export default function useMonacoOnMount(): UseMonacoOnMountReturn { + return { + onMount(editor: editor.IStandaloneCodeEditor): void { + editor.focus(); + const model = editor.getModel(); + if (model) { + const lastLine = model.getLineCount(); + const lastColumn = model.getLineMaxColumn(lastLine); + editor.setPosition({ lineNumber: lastLine, column: lastColumn }); + } + }, + }; +} diff --git a/frontend/apps/web/libs/hooks/monaco/useMonacoResizer.ts b/frontend/apps/web/libs/hooks/monaco/useMonacoResizer.ts new file mode 100644 index 0000000000..be6cfec42d --- /dev/null +++ b/frontend/apps/web/libs/hooks/monaco/useMonacoResizer.ts @@ -0,0 +1,36 @@ +// Offset is important here as without it, things get pretty strange I believe due to the container + +import { useMemo } from 'react'; +import { useResizeDetector } from 'react-resize-detector'; +import { OnRefChangeType } from 'react-resize-detector/build/types/types'; + +// Lower offsets cause the resize to happen at a glacial pace, and without one, not at all. +const WIDTH_OFFSET = 10; + +interface UseMonacoResizerReturn { + ref: OnRefChangeType; + width: string; +} + +export default function useMonacoResizer(): UseMonacoResizerReturn { + const { ref, width } = useResizeDetector({ + handleHeight: false, + handleWidth: true, + refreshMode: 'debounce', + refreshRate: 10, + skipOnMount: false, + }); + + const editorWidth = useMemo( + () => + width != null && width > WIDTH_OFFSET + ? `${width - WIDTH_OFFSET}px` + : '100%', + [width] + ); + + return { + ref, + width: editorWidth, + }; +} diff --git a/frontend/apps/web/libs/hooks/monaco/useMonacoTheme.ts b/frontend/apps/web/libs/hooks/monaco/useMonacoTheme.ts new file mode 100644 index 0000000000..6746982d5c --- /dev/null +++ b/frontend/apps/web/libs/hooks/monaco/useMonacoTheme.ts @@ -0,0 +1,13 @@ +import { useTheme } from 'next-themes'; +import { useMemo } from 'react'; + +const DARK_THEME = 'vs-dark'; +const LIGHT_THEME = 'light'; + +export default function useMonacoTheme(): string { + const { resolvedTheme } = useTheme(); + return useMemo( + () => (resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME), + [resolvedTheme] + ); +}