From 2ce091118667ddf5528c43463f24d80cb3fc0b18 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Fri, 5 Sep 2025 10:47:07 +0300 Subject: [PATCH] feat(editor): Refactor data store table component (no-changelog) (#19131) --- .../dataGrid/DataStoreTable.test.ts | 11 + .../components/dataGrid/DataStoreTable.vue | 846 ++---------------- .../composables/useDataStoreGridBase.ts | 401 +++++++++ .../useDataStoreOperations.test.ts | 86 ++ .../composables/useDataStoreOperations.ts | 319 +++++++ .../composables/useDataStorePagination.ts | 50 ++ .../composables/useDataStoreSelection.test.ts | 89 ++ .../composables/useDataStoreSelection.ts | 45 + .../src/features/dataStore/dataStore.store.ts | 6 +- .../features/dataStore/utils/columnUtils.ts | 95 ++ 10 files changed, 1186 insertions(+), 762 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreGridBase.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.test.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/composables/useDataStorePagination.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.test.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/utils/columnUtils.ts diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts index b03b58a2a1..5d50bd7c62 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts @@ -47,6 +47,7 @@ vi.mock('ag-grid-community', () => ({ CellStyleModule: {}, ScrollApiModule: {}, PinnedRowModule: {}, + ColumnApiModule: {}, })); // Mock the n8n theme @@ -73,6 +74,16 @@ vi.mock('@/composables/useToast', () => ({ }), })); +vi.mock('@/features/dataStore/composables/useDataStorePagination', () => ({ + useDataStorePagination: () => ({ + totalItems: 0, + setTotalItems: vi.fn(), + ensureItemOnPage: vi.fn(), + currentPage: 1, + setCurrentPage: vi.fn(), + }), +})); + vi.mock('@n8n/i18n', async (importOriginal) => ({ ...(await importOriginal()), useI18n: () => ({ diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue index bbdbc4efa6..5b728ed9da 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue @@ -1,32 +1,12 @@ diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreGridBase.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreGridBase.ts new file mode 100644 index 0000000000..3961b396c2 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreGridBase.ts @@ -0,0 +1,401 @@ +import { computed, ref, type Ref } from 'vue'; +import type { + CellClickedEvent, + CellEditingStartedEvent, + CellEditingStoppedEvent, + CellKeyDownEvent, + ColDef, + GridApi, + GridReadyEvent, + ICellRendererParams, + SortChangedEvent, + SortDirection, +} from 'ag-grid-community'; +import type { + DataStoreColumn, + DataStoreColumnCreatePayload, + DataStoreRow, +} from '@/features/dataStore/datastore.types'; +import { + ADD_ROW_ROW_ID, + DATA_STORE_ID_COLUMN_WIDTH, + DEFAULT_ID_COLUMN_NAME, +} from '@/features/dataStore/constants'; +import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes'; +import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue'; +import ElDatePickerCellEditor from '@/features/dataStore/components/dataGrid/ElDatePickerCellEditor.vue'; +import orderBy from 'lodash/orderBy'; +import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue'; +import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.vue'; +import { reorderItem } from '@/features/dataStore/utils'; +import { useClipboard } from '@/composables/useClipboard'; +import { onClickOutside } from '@vueuse/core'; +import { + getCellClass, + createValueGetter, + createCellRendererSelector, + createStringValueSetter, + stringCellEditorParams, + dateValueFormatter, +} from '@/features/dataStore/utils/columnUtils'; + +export const useDataStoreGridBase = ({ + gridContainerRef, + onDeleteColumn, + onAddRowClick, + onAddColumn, +}: { + gridContainerRef: Ref; + onDeleteColumn: (columnId: string) => void; + onAddRowClick: () => void; + onAddColumn: (column: DataStoreColumnCreatePayload) => Promise; +}) => { + const gridApi = ref(null); + const colDefs = ref([]); + const isTextEditorOpen = ref(false); + const { mapToAGCellType } = useDataStoreTypes(); + const { copy: copyToClipboard } = useClipboard({ onPaste: onClipboardPaste }); + const currentSortBy = ref(DEFAULT_ID_COLUMN_NAME); + const currentSortOrder = ref('asc'); + + // Track the last focused cell so we can start editing when users click on it + // AG Grid doesn't provide cell blur event so we need to reset this manually + const lastFocusedCell = ref<{ rowIndex: number; colId: string } | null>(null); + const initializedGridApi = computed(() => { + if (!gridApi.value) { + throw new Error('Grid API is not initialized'); + } + return gridApi.value; + }); + + const onGridReady = (params: GridReadyEvent) => { + gridApi.value = params.api; + // Ensure popups (e.g., agLargeTextCellEditor) are positioned relative to the grid container + // to avoid misalignment when the page scrolls. + if (gridContainerRef.value) { + params.api.setGridOption('popupParent', gridContainerRef.value); + } + }; + + const setGridData = ({ + colDefs, + rowData, + }: { + colDefs?: ColDef[]; + rowData?: DataStoreRow[]; + }) => { + if (colDefs) { + initializedGridApi.value.setGridOption('columnDefs', colDefs); + } + + if (rowData) { + initializedGridApi.value.setGridOption('rowData', rowData); + } + + initializedGridApi.value.setGridOption('pinnedBottomRowData', [{ id: ADD_ROW_ROW_ID }]); + }; + + const focusFirstEditableCell = (rowId: number) => { + const rowNode = initializedGridApi.value.getRowNode(String(rowId)); + if (rowNode?.rowIndex === null) return; + + const firstEditableCol = colDefs.value[1]; + if (!firstEditableCol?.colId) return; + + initializedGridApi.value.ensureIndexVisible(rowNode!.rowIndex); + initializedGridApi.value.setFocusedCell(rowNode!.rowIndex, firstEditableCol.colId); + initializedGridApi.value.startEditingCell({ + rowIndex: rowNode!.rowIndex, + colKey: firstEditableCol.colId, + }); + }; + + const createColumnDef = (col: DataStoreColumn, extraProps: Partial = {}) => { + const columnDef: ColDef = { + colId: col.id, + field: col.name, + headerName: col.name, + sortable: true, + flex: 1, + editable: (params) => params.data?.id !== ADD_ROW_ROW_ID, + resizable: true, + lockPinned: true, + headerComponent: ColumnHeader, + headerComponentParams: { onDelete: onDeleteColumn, allowMenuActions: true }, + cellEditorPopup: false, + cellDataType: mapToAGCellType(col.type), + cellClass: getCellClass, + valueGetter: createValueGetter(col), + cellRendererSelector: createCellRendererSelector(col), + }; + + if (col.type === 'string') { + columnDef.cellEditor = 'agLargeTextCellEditor'; + columnDef.cellEditorPopup = true; + columnDef.cellEditorPopupPosition = 'over'; + columnDef.cellEditorParams = stringCellEditorParams; + columnDef.valueSetter = createStringValueSetter(col, isTextEditorOpen); + } else if (col.type === 'date') { + columnDef.cellEditorSelector = () => ({ + component: ElDatePickerCellEditor, + }); + columnDef.valueFormatter = dateValueFormatter; + } + + return { + ...columnDef, + ...extraProps, + }; + }; + + const onCellEditingStarted = (params: CellEditingStartedEvent) => { + if (params.column.getColDef().cellDataType === 'text') { + isTextEditorOpen.value = true; + } else { + isTextEditorOpen.value = false; + } + }; + + const onCellEditingStopped = (params: CellEditingStoppedEvent) => { + if (params.column.getColDef().cellDataType === 'text') { + isTextEditorOpen.value = false; + } + }; + + const getColumnDefinitions = (dataStoreColumns: DataStoreColumn[]) => { + const systemDateColumnOptions: Partial = { + editable: false, + suppressMovable: true, + lockPinned: true, + lockPosition: 'right', + headerComponentParams: { + allowMenuActions: false, + }, + }; + return [ + // Always add the ID column, it's not returned by the back-end but all data stores have it + // We use it as a placeholder for new datastores + createColumnDef( + { + index: 0, + id: DEFAULT_ID_COLUMN_NAME, + name: DEFAULT_ID_COLUMN_NAME, + type: 'string', + }, + { + editable: false, + sortable: false, + suppressMovable: true, + headerComponent: null, + lockPosition: true, + minWidth: DATA_STORE_ID_COLUMN_WIDTH, + maxWidth: DATA_STORE_ID_COLUMN_WIDTH, + resizable: false, + cellClass: (params) => + params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'id-column', + cellRendererSelector: (params: ICellRendererParams) => { + if (params.value === ADD_ROW_ROW_ID) { + return { + component: AddRowButton, + params: { onClick: onAddRowClick }, + }; + } + return undefined; + }, + }, + ), + // Append other columns + ...orderBy(dataStoreColumns, 'index').map((col) => createColumnDef(col)), + createColumnDef( + { + index: dataStoreColumns.length + 1, + id: 'createdAt', + name: 'createdAt', + type: 'date', + }, + systemDateColumnOptions, + ), + createColumnDef( + { + index: dataStoreColumns.length + 2, + id: 'updatedAt', + name: 'updatedAt', + type: 'date', + }, + systemDateColumnOptions, + ), + createColumnDef( + { + index: dataStoreColumns.length + 3, + id: 'add-column', + name: 'Add Column', + type: 'string', + }, + { + editable: false, + suppressMovable: true, + lockPinned: true, + lockPosition: 'right', + resizable: false, + headerComponent: AddColumnButton, + headerComponentParams: { onAddColumn }, + }, + ), + ]; + }; + + const loadColumns = (dataStoreColumns: DataStoreColumn[]) => { + colDefs.value = getColumnDefinitions(dataStoreColumns); + setGridData({ colDefs: colDefs.value }); + }; + + const deleteColumn = (columnId: string) => { + colDefs.value = colDefs.value.filter((col) => col.colId !== columnId); + setGridData({ colDefs: colDefs.value }); + }; + + const insertColumnAtIndex = (column: ColDef, index: number) => { + colDefs.value.splice(index, 0, column); + setGridData({ colDefs: colDefs.value }); + }; + + const addColumn = (column: DataStoreColumn) => { + colDefs.value = [ + ...colDefs.value.slice(0, -1), + createColumnDef(column), + ...colDefs.value.slice(-1), + ]; + setGridData({ colDefs: colDefs.value }); + }; + + const moveColumn = (oldIndex: number, newIndex: number) => { + const fromIndex = oldIndex - 1; // exclude ID column + const columnToBeMoved = colDefs.value[fromIndex]; + if (!columnToBeMoved) { + return; + } + const middleWithIndex = colDefs.value.slice(1, -1).map((col, index) => ({ ...col, index })); + const reorderedMiddle = reorderItem(middleWithIndex, fromIndex, newIndex) + .sort((a, b) => a.index - b.index) + .map(({ index, ...col }) => col); + colDefs.value = [colDefs.value[0], ...reorderedMiddle, colDefs.value[colDefs.value.length - 1]]; + }; + + const handleCopyFocusedCell = async (params: CellKeyDownEvent) => { + const focused = params.api.getFocusedCell(); + if (!focused) { + return; + } + const row = params.api.getDisplayedRowAtIndex(focused.rowIndex); + const colDef = focused.column.getColDef(); + if (row?.data && colDef.field) { + const rawValue = row.data[colDef.field]; + const text = rawValue === null || rawValue === undefined ? '' : String(rawValue); + await copyToClipboard(text); + } + }; + + function onClipboardPaste(data: string) { + const focusedCell = initializedGridApi.value.getFocusedCell(); + const isEditing = initializedGridApi.value.getEditingCells().length > 0; + if (!focusedCell || isEditing) return; + const row = initializedGridApi.value.getDisplayedRowAtIndex(focusedCell.rowIndex); + if (!row) return; + + const colDef = focusedCell.column.getColDef(); + if (colDef.cellDataType === 'text') { + row.setDataValue(focusedCell.column.getColId(), data); + } else if (colDef.cellDataType === 'number') { + if (!Number.isNaN(Number(data))) { + row.setDataValue(focusedCell.column.getColId(), Number(data)); + } + } else if (colDef.cellDataType === 'date') { + if (!Number.isNaN(Date.parse(data))) { + row.setDataValue(focusedCell.column.getColId(), new Date(data)); + } + } else if (colDef.cellDataType === 'boolean') { + if (data === 'true') { + row.setDataValue(focusedCell.column.getColId(), true); + } else if (data === 'false') { + row.setDataValue(focusedCell.column.getColId(), false); + } + } + } + + const onCellClicked = (params: CellClickedEvent) => { + const clickedCellColumn = params.column.getColId(); + const clickedCellRow = params.rowIndex; + + if ( + clickedCellRow === null || + params.api.isEditing({ rowIndex: clickedCellRow, column: params.column, rowPinned: null }) + ) + return; + + // Check if this is the same cell that was focused before this click + const wasAlreadyFocused = + lastFocusedCell.value && + lastFocusedCell.value.rowIndex === clickedCellRow && + lastFocusedCell.value.colId === clickedCellColumn; + + if (wasAlreadyFocused && params.column.getColDef()?.editable) { + // Cell was already selected, start editing + params.api.startEditingCell({ + rowIndex: clickedCellRow, + colKey: clickedCellColumn, + }); + } + + // Update the last focused cell for next click + lastFocusedCell.value = { + rowIndex: clickedCellRow, + colId: clickedCellColumn, + }; + }; + + const resetLastFocusedCell = () => { + lastFocusedCell.value = null; + }; + + const onSortChanged = async (event: SortChangedEvent) => { + const sortedColumn = event.columns?.filter((col) => col.getSort() !== null).pop() ?? null; + + if (sortedColumn) { + const colId = sortedColumn.getColId(); + const columnDef = colDefs.value.find((col) => col.colId === colId); + + currentSortBy.value = columnDef?.field || colId; + currentSortOrder.value = sortedColumn.getSort() ?? 'asc'; + } else { + currentSortBy.value = DEFAULT_ID_COLUMN_NAME; + currentSortOrder.value = 'asc'; + } + }; + + onClickOutside(gridContainerRef, () => { + resetLastFocusedCell(); + initializedGridApi.value.clearFocusedCell(); + }); + + return { + onGridReady, + setGridData, + focusFirstEditableCell, + onCellEditingStarted, + onCellEditingStopped, + createColumnDef, + loadColumns, + colDefs, + deleteColumn, + insertColumnAtIndex, + addColumn, + moveColumn, + gridApi: initializedGridApi, + handleCopyFocusedCell, + onCellClicked, + resetLastFocusedCell, + currentSortBy, + currentSortOrder, + onSortChanged, + }; +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.test.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.test.ts new file mode 100644 index 0000000000..58abf7ae82 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.test.ts @@ -0,0 +1,86 @@ +import { + useDataStoreOperations, + type UseDataStoreOperationsParams, +} from '@/features/dataStore/composables/useDataStoreOperations'; +import { ref } from 'vue'; +import type { GridApi } from 'ag-grid-community'; +import { setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; + +vi.mock('@/features/dataStore/dataStore.store', () => ({ + useDataStoreStore: vi.fn(() => ({})), +})); + +describe('useDataStoreOperations', () => { + let params: UseDataStoreOperationsParams; + let dataStoreStore: ReturnType; + beforeEach(() => { + setActivePinia(createTestingPinia()); + + dataStoreStore = { + addDataStoreColumn: vi.fn(), + deleteDataStoreColumn: vi.fn(), + moveDataStoreColumn: vi.fn(), + deleteRows: vi.fn(), + insertEmptyRow: vi.fn(), + } as unknown as ReturnType; + + vi.mocked(useDataStoreStore).mockReturnValue(dataStoreStore); + + params = { + colDefs: ref([]), + rowData: ref([]), + deleteGridColumn: vi.fn(), + addGridColumn: vi.fn(), + setGridData: vi.fn(), + insertGridColumnAtIndex: vi.fn(), + moveGridColumn: vi.fn(), + dataStoreId: 'test', + projectId: 'test', + gridApi: ref(null as unknown as GridApi), + totalItems: ref(0), + setTotalItems: vi.fn(), + ensureItemOnPage: vi.fn(), + focusFirstEditableCell: vi.fn(), + toggleSave: vi.fn(), + currentPage: ref(1), + pageSize: ref(10), + currentSortBy: ref(''), + currentSortOrder: ref(null), + handleClearSelection: vi.fn(), + selectedRowIds: ref(new Set()), + handleCopyFocusedCell: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('onAddColumn', () => { + it('should raise error when column is not added', async () => { + vi.mocked(useDataStoreStore).mockReturnValue({ + ...dataStoreStore, + addDataStoreColumn: vi.fn().mockRejectedValue(new Error('test')), + }); + const { onAddColumn } = useDataStoreOperations(params); + const result = await onAddColumn({ name: 'test', type: 'string' }); + expect(result).toBe(false); + }); + + it('should add column when column is added', async () => { + const returnedColumn = { name: 'test', type: 'string' } as const; + vi.mocked(useDataStoreStore).mockReturnValue({ + ...dataStoreStore, + addDataStoreColumn: vi.fn().mockResolvedValue(returnedColumn), + }); + const rowData = ref([{ id: 1 }]); + const { onAddColumn } = useDataStoreOperations({ ...params, rowData }); + const result = await onAddColumn({ name: returnedColumn.name, type: returnedColumn.type }); + expect(result).toBe(true); + expect(params.setGridData).toHaveBeenCalledWith({ rowData: [{ id: 1, test: null }] }); + expect(params.addGridColumn).toHaveBeenCalledWith(returnedColumn); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.ts new file mode 100644 index 0000000000..1880343c11 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreOperations.ts @@ -0,0 +1,319 @@ +import { useMessage } from '@/composables/useMessage'; +import { useToast } from '@/composables/useToast'; +import { useTelemetry } from '@/composables/useTelemetry'; +import type { + DataStoreColumn, + DataStoreColumnCreatePayload, + DataStoreRow, +} from '@/features/dataStore/datastore.types'; +import { ref, type Ref } from 'vue'; +import { useI18n } from '@n8n/i18n'; +import type { + CellKeyDownEvent, + CellValueChangedEvent, + ColDef, + ColumnMovedEvent, + GridApi, +} from 'ag-grid-community'; +import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; +import { MODAL_CONFIRM } from '@/constants'; +import { isDataStoreValue } from '@/features/dataStore/typeGuards'; + +export type UseDataStoreOperationsParams = { + colDefs: Ref; + rowData: Ref; + deleteGridColumn: (columnId: string) => void; + addGridColumn: (column: DataStoreColumn) => void; + setGridData: (params: { rowData?: DataStoreRow[]; colDefs?: ColDef[] }) => void; + insertGridColumnAtIndex: (column: ColDef, index: number) => void; + moveGridColumn: (oldIndex: number, newIndex: number) => void; + dataStoreId: string; + projectId: string; + gridApi: Ref; + totalItems: Ref; + setTotalItems: (count: number) => void; + ensureItemOnPage: (itemIndex: number) => Promise; + focusFirstEditableCell: (rowId: number) => void; + toggleSave: (value: boolean) => void; + currentPage: Ref; + pageSize: Ref; + currentSortBy: Ref; + currentSortOrder: Ref; + handleClearSelection: () => void; + selectedRowIds: Ref>; + handleCopyFocusedCell: (params: CellKeyDownEvent) => Promise; +}; + +export const useDataStoreOperations = ({ + colDefs, + rowData, + deleteGridColumn, + addGridColumn, + setGridData, + insertGridColumnAtIndex, + moveGridColumn, + dataStoreId, + projectId, + gridApi, + totalItems, + setTotalItems, + ensureItemOnPage, + focusFirstEditableCell, + toggleSave, + currentPage, + pageSize, + currentSortBy, + currentSortOrder, + handleClearSelection, + selectedRowIds, + handleCopyFocusedCell, +}: UseDataStoreOperationsParams) => { + const i18n = useI18n(); + const toast = useToast(); + const message = useMessage(); + const dataStoreStore = useDataStoreStore(); + const contentLoading = ref(false); + const telemetry = useTelemetry(); + + async function onDeleteColumn(columnId: string) { + const columnToDelete = colDefs.value.find((col) => col.colId === columnId); + if (!columnToDelete) return; + + const promptResponse = await message.confirm( + i18n.baseText('dataStore.deleteColumn.confirm.message', { + interpolate: { name: columnToDelete.headerName ?? '' }, + }), + i18n.baseText('dataStore.deleteColumn.confirm.title'), + { + confirmButtonText: i18n.baseText('generic.delete'), + cancelButtonText: i18n.baseText('generic.cancel'), + }, + ); + + if (promptResponse !== MODAL_CONFIRM) { + return; + } + + const columnToDeleteIndex = colDefs.value.findIndex((col) => col.colId === columnId); + deleteGridColumn(columnId); + const rowDataOldValue = [...rowData.value]; + rowData.value = rowData.value.map((row) => { + const { [columnToDelete.field!]: _, ...rest } = row; + return rest; + }); + setGridData({ rowData: rowData.value }); + try { + await dataStoreStore.deleteDataStoreColumn(dataStoreId, projectId, columnId); + telemetry.track('User deleted data table column', { + column_id: columnId, + column_type: columnToDelete.cellDataType, + data_table_id: dataStoreId, + }); + } catch (error) { + toast.showError(error, i18n.baseText('dataStore.deleteColumn.error')); + insertGridColumnAtIndex(columnToDelete, columnToDeleteIndex); + rowData.value = rowDataOldValue; + setGridData({ rowData: rowData.value }); + } + } + + async function onAddColumn(column: DataStoreColumnCreatePayload) { + try { + const newColumn = await dataStoreStore.addDataStoreColumn(dataStoreId, projectId, column); + addGridColumn(newColumn); + rowData.value = rowData.value.map((row) => { + return { ...row, [newColumn.name]: null }; + }); + setGridData({ rowData: rowData.value }); + telemetry.track('User added data table column', { + column_id: newColumn.id, + column_type: newColumn.type, + data_table_id: dataStoreId, + }); + return true; + } catch (error) { + toast.showError(error, i18n.baseText('dataStore.addColumn.error')); + return false; + } + } + + const onColumnMoved = async (moveEvent: ColumnMovedEvent) => { + if ( + !moveEvent.finished || + moveEvent.source !== 'uiColumnMoved' || + moveEvent.toIndex === undefined || + !moveEvent.column + ) { + return; + } + + const oldIndex = colDefs.value.findIndex((col) => col.colId === moveEvent.column!.getColId()); + const newIndex = moveEvent.toIndex - 2; // selection and id columns are included here + try { + await dataStoreStore.moveDataStoreColumn( + dataStoreId, + projectId, + moveEvent.column.getColId(), + newIndex, + ); + moveGridColumn(oldIndex, newIndex); + } catch (error) { + toast.showError(error, i18n.baseText('dataStore.moveColumn.error')); + gridApi.value.moveColumnByIndex(moveEvent.toIndex, oldIndex + 1); + } + }; + + async function onAddRowClick() { + try { + await ensureItemOnPage(totalItems.value + 1); + + contentLoading.value = true; + toggleSave(true); + const insertedRow = await dataStoreStore.insertEmptyRow(dataStoreId, projectId); + const newRow: DataStoreRow = insertedRow; + rowData.value.push(newRow); + setTotalItems(totalItems.value + 1); + setGridData({ rowData: rowData.value }); + focusFirstEditableCell(newRow.id as number); + telemetry.track('User added row to data table', { + data_table_id: dataStoreId, + }); + } catch (error) { + toast.showError(error, i18n.baseText('dataStore.addRow.error')); + } finally { + toggleSave(false); + contentLoading.value = false; + } + } + + const onCellValueChanged = async (params: CellValueChangedEvent) => { + const { data, api, oldValue, colDef } = params; + const value = params.data[colDef.field!]; + + if (value === undefined || value === oldValue) { + return; + } + + if (typeof data.id !== 'number') { + throw new Error('Expected row id to be a number'); + } + const fieldName = String(colDef.field); + const id = data.id; + + try { + toggleSave(true); + await dataStoreStore.updateRow(dataStoreId, projectId, id, { + [fieldName]: value, + }); + telemetry.track('User edited data table content', { + data_table_id: dataStoreId, + column_id: colDef.colId, + column_type: colDef.cellDataType, + }); + } catch (error) { + // Revert cell to original value if the update fails + const validOldValue = isDataStoreValue(oldValue) ? oldValue : null; + const revertedData: DataStoreRow = { ...data, [fieldName]: validOldValue }; + api.applyTransaction({ + update: [revertedData], + }); + toast.showError(error, i18n.baseText('dataStore.updateRow.error')); + } finally { + toggleSave(false); + } + }; + + async function fetchDataStoreRows() { + try { + contentLoading.value = true; + + const fetchedRows = await dataStoreStore.fetchDataStoreContent( + dataStoreId, + projectId, + currentPage.value, + pageSize.value, + `${currentSortBy.value}:${currentSortOrder.value}`, + ); + rowData.value = fetchedRows.data; + setTotalItems(fetchedRows.count); + setGridData({ rowData: rowData.value, colDefs: colDefs.value }); + handleClearSelection(); + } catch (error) { + toast.showError(error, i18n.baseText('dataStore.fetchContent.error')); + } finally { + contentLoading.value = false; + } + } + + const handleDeleteSelected = async () => { + if (selectedRowIds.value.size === 0) return; + + const confirmResponse = await message.confirm( + i18n.baseText('dataStore.deleteRows.confirmation', { + adjustToNumber: selectedRowIds.value.size, + interpolate: { count: selectedRowIds.value.size }, + }), + i18n.baseText('dataStore.deleteRows.title'), + { + confirmButtonText: i18n.baseText('generic.delete'), + cancelButtonText: i18n.baseText('generic.cancel'), + }, + ); + + if (confirmResponse !== MODAL_CONFIRM) { + return; + } + + try { + toggleSave(true); + const idsToDelete = Array.from(selectedRowIds.value); + await dataStoreStore.deleteRows(dataStoreId, projectId, idsToDelete); + await fetchDataStoreRows(); + + toast.showToast({ + title: i18n.baseText('dataStore.deleteRows.success'), + message: '', + type: 'success', + }); + telemetry.track('User deleted rows in data table', { + data_table_id: dataStoreId, + deleted_row_count: idsToDelete.length, + }); + } catch (error) { + toast.showError(error, i18n.baseText('dataStore.deleteRows.error')); + } finally { + toggleSave(false); + } + }; + + const onCellKeyDown = async (params: CellKeyDownEvent) => { + if (params.api.getEditingCells().length > 0) { + return; + } + + const event = params.event as KeyboardEvent; + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') { + event.preventDefault(); + await handleCopyFocusedCell(params); + return; + } + + if ((event.key !== 'Delete' && event.key !== 'Backspace') || selectedRowIds.value.size === 0) { + return; + } + event.preventDefault(); + await handleDeleteSelected(); + }; + + return { + onDeleteColumn, + onAddColumn, + onColumnMoved, + onAddRowClick, + contentLoading, + onCellValueChanged, + fetchDataStoreRows, + handleDeleteSelected, + onCellKeyDown, + }; +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStorePagination.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStorePagination.ts new file mode 100644 index 0000000000..cd56c27f93 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStorePagination.ts @@ -0,0 +1,50 @@ +import { ref } from 'vue'; + +export type PageSize = 10 | 20 | 50; +export type UseDataStorePaginationOptions = { + initialPage?: number; + initialPageSize?: PageSize; + pageSizeOptions?: PageSize[]; + onChange?: (page: number, pageSize: number) => Promise | void; +}; + +export const useDataStorePagination = (options: UseDataStorePaginationOptions = {}) => { + const currentPage = ref(options.initialPage ?? 1); + const pageSize = ref(options.initialPageSize ?? 20); + const totalItems = ref(0); + const pageSizeOptions = options.pageSizeOptions ?? [10, 20, 50]; + + const setTotalItems = (count: number) => { + totalItems.value = count; + }; + + const setCurrentPage = async (page: number) => { + currentPage.value = page; + if (options.onChange) await options.onChange(currentPage.value, pageSize.value); + }; + + const setPageSize = async (size: PageSize) => { + pageSize.value = size; + currentPage.value = 1; + if (options.onChange) await options.onChange(currentPage.value, pageSize.value); + }; + + const ensureItemOnPage = async (itemIndex: number) => { + const itemPage = Math.max(1, Math.ceil(itemIndex / pageSize.value)); + if (currentPage.value !== itemPage) { + currentPage.value = itemPage; + if (options.onChange) await options.onChange(currentPage.value, pageSize.value); + } + }; + + return { + currentPage, + pageSize, + pageSizeOptions, + totalItems, + setTotalItems, + setCurrentPage, + setPageSize, + ensureItemOnPage, + }; +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.test.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.test.ts new file mode 100644 index 0000000000..9913161589 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.test.ts @@ -0,0 +1,89 @@ +import { ref } from 'vue'; +import type { GridApi, IRowNode } from 'ag-grid-community'; +import { useDataStoreSelection } from './useDataStoreSelection'; + +const createMockGridApi = () => + ({ + getSelectedNodes: vi.fn(), + deselectAll: vi.fn(), + }) as unknown as GridApi; + +describe('useDataStoreSelection', () => { + let mockGridApi: GridApi; + + beforeEach(() => { + mockGridApi = createMockGridApi(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('onSelectionChanged', () => { + it('should update selectedRowIds with numeric IDs from selected nodes', () => { + const gridApi = ref(mockGridApi); + const { selectedRowIds, onSelectionChanged } = useDataStoreSelection({ gridApi }); + + const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }, { data: { id: 3 } }]; + + vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]); + + onSelectionChanged(); + + expect(selectedRowIds.value).toEqual(new Set([1, 2, 3])); + }); + + it('should filter out non-numeric IDs', () => { + const gridApi = ref(mockGridApi); + const { selectedRowIds, onSelectionChanged } = useDataStoreSelection({ gridApi }); + + const mockSelectedNodes = [ + { data: { id: 1 } }, + { data: { id: 'string-id' } }, + { data: { id: 2 } }, + { data: { id: null } }, + { data: { id: undefined } }, + ]; + + vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]); + + onSelectionChanged(); + + expect(selectedRowIds.value).toEqual(new Set([1, 2])); + }); + + it('should update selectedCount reactively', () => { + const gridApi = ref(mockGridApi); + const { selectedCount, onSelectionChanged } = useDataStoreSelection({ gridApi }); + + const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }, { data: { id: 3 } }]; + + vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]); + + onSelectionChanged(); + + expect(selectedCount.value).toBe(3); + }); + }); + + describe('handleClearSelection', () => { + it('should clear selectedRowIds and call deselectAll on grid', () => { + const gridApi = ref(mockGridApi); + const { selectedRowIds, handleClearSelection, onSelectionChanged } = useDataStoreSelection({ + gridApi, + }); + + // First select some rows + const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }]; + vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]); + onSelectionChanged(); + expect(selectedRowIds.value).toEqual(new Set([1, 2])); + + // Clear selection + handleClearSelection(); + + expect(selectedRowIds.value).toEqual(new Set()); + expect(mockGridApi.deselectAll).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.ts new file mode 100644 index 0000000000..379784b670 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreSelection.ts @@ -0,0 +1,45 @@ +import { computed, ref, type Ref } from 'vue'; +import type { GridApi, RowSelectionOptions } from 'ag-grid-community'; +import { ADD_ROW_ROW_ID } from '@/features/dataStore/constants'; + +export const useDataStoreSelection = ({ + gridApi, +}: { + gridApi: Ref; +}) => { + const selectedRowIds = ref>(new Set()); + const selectedCount = computed(() => selectedRowIds.value.size); + + const rowSelection: RowSelectionOptions | 'single' | 'multiple' = { + mode: 'multiRow', + enableClickSelection: false, + checkboxes: (params) => params.data?.id !== ADD_ROW_ROW_ID, + isRowSelectable: (params) => params.data?.id !== ADD_ROW_ROW_ID, + }; + + const onSelectionChanged = () => { + const selectedNodes = gridApi.value.getSelectedNodes(); + const newSelectedIds = new Set(); + + selectedNodes.forEach((node) => { + if (typeof node.data?.id === 'number') { + newSelectedIds.add(node.data.id); + } + }); + + selectedRowIds.value = newSelectedIds; + }; + + const handleClearSelection = () => { + selectedRowIds.value = new Set(); + gridApi.value.deselectAll(); + }; + + return { + selectedRowIds, + selectedCount, + rowSelection, + onSelectionChanged, + handleClearSelection, + }; +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts index 08e8e264f4..064ed487c1 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts @@ -178,12 +178,12 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { }); }; - const insertEmptyRow = async (dataStore: DataStore) => { + const insertEmptyRow = async (dataStoreId: string, projectId: string) => { const inserted = await insertDataStoreRowApi( rootStore.restApiContext, - dataStore.id, + dataStoreId, {}, - dataStore.projectId, + projectId, ); return inserted[0]; }; diff --git a/packages/frontend/editor-ui/src/features/dataStore/utils/columnUtils.ts b/packages/frontend/editor-ui/src/features/dataStore/utils/columnUtils.ts new file mode 100644 index 0000000000..ab5e9687c8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/utils/columnUtils.ts @@ -0,0 +1,95 @@ +import type { + CellClassParams, + ICellRendererParams, + ValueGetterParams, + ValueSetterParams, + CellEditRequestEvent, + ValueFormatterParams, +} from 'ag-grid-community'; +import type { Ref } from 'vue'; +import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types'; +import { ADD_ROW_ROW_ID, EMPTY_VALUE, NULL_VALUE } from '@/features/dataStore/constants'; +import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue'; +import { isDataStoreValue } from '@/features/dataStore/typeGuards'; + +export const getCellClass = (params: CellClassParams): string => { + if (params.data?.id === ADD_ROW_ROW_ID) { + return 'add-row-cell'; + } + if (params.column.getUserProvidedColDef()?.cellDataType === 'boolean') { + return 'boolean-cell'; + } + return ''; +}; + +export const createValueGetter = + (col: DataStoreColumn) => (params: ValueGetterParams) => { + if (params.data?.[col.name] === null || params.data?.[col.name] === undefined) { + return null; + } + if (col.type === 'date') { + const value = params.data?.[col.name]; + if (typeof value === 'string') { + return new Date(value); + } + } + return params.data?.[col.name]; + }; + +export const createCellRendererSelector = + (col: DataStoreColumn) => (params: ICellRendererParams) => { + if (params.data?.id === ADD_ROW_ROW_ID || col.id === 'add-column') { + return {}; + } + let rowValue = (params.data as DataStoreRow | undefined)?.[col.name]; + if (rowValue === undefined) { + rowValue = null; + } + if (rowValue === null) { + return { component: NullEmptyCellRenderer, params: { value: NULL_VALUE } }; + } + if (rowValue === '') { + return { component: NullEmptyCellRenderer, params: { value: EMPTY_VALUE } }; + } + return undefined; + }; + +export const createStringValueSetter = + (col: DataStoreColumn, isTextEditorOpen: Ref) => + (params: ValueSetterParams) => { + let originalValue = params.data[col.name]; + if (originalValue === undefined) { + originalValue = null; + } + let newValue = params.newValue as unknown as DataStoreRow[keyof DataStoreRow]; + + if (!isDataStoreValue(newValue)) { + return false; + } + + if (originalValue === null && newValue === '') { + return false; + } + + if (isTextEditorOpen.value && newValue === null) { + newValue = ''; + } + + params.data[col.name] = newValue; + return true; + }; + +export const stringCellEditorParams = ( + params: CellEditRequestEvent, +): { value: string; maxLength: number } => ({ + value: (params.value as string | null | undefined) ?? '', + maxLength: 999999999, +}); + +export const dateValueFormatter = ( + params: ValueFormatterParams, +): string => { + const value = params.value; + if (value === null || value === undefined) return ''; + return value.toISOString(); +};