From f67b7f2ba28dbb11649adf422b28abc4574aa527 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Wed, 27 Aug 2025 15:01:17 +0300 Subject: [PATCH] feat(editor): Data table UI redesign (no-changelog) (#18814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Milorad FIlipović --- .../N8nActionDropdown/ActionDropdown.vue | 1 + .../design-system/src/css/_tokens.dark.scss | 3 + .../@n8n/design-system/src/css/_tokens.scss | 3 + .../frontend/@n8n/i18n/src/locales/en.json | 4 +- .../components/Projects/ProjectHeader.test.ts | 2 + .../src/components/Projects/ProjectHeader.vue | 12 +- .../dataStore/DataStoreDetailsView.vue | 41 ++- .../components/DataStoreBreadcrumbs.vue | 1 + ...opover.test.ts => AddColumnButton.test.ts} | 70 +++-- ...dColumnPopover.vue => AddColumnButton.vue} | 120 ++++----- .../components/dataGrid/AddRowButton.vue | 18 ++ .../dataGrid/DataStoreTable.test.ts | 35 +-- .../components/dataGrid/DataStoreTable.vue | 251 ++++++++++++++---- .../dataGrid/NullEmptyCellRenderer.vue | 2 +- .../dataStore/components/dataGrid/n8nTheme.ts | 4 + .../src/features/dataStore/constants.ts | 7 + .../src/features/dataStore/dataStore.api.ts | 2 +- .../src/features/dataStore/dataStore.store.ts | 2 +- .../editor-ui/src/stores/settings.store.ts | 2 +- 19 files changed, 375 insertions(+), 205 deletions(-) rename packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/{AddColumnPopover.test.ts => AddColumnButton.test.ts} (87%) rename packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/{AddColumnPopover.vue => AddColumnButton.vue} (70%) create mode 100644 packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddRowButton.vue diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index 3bb4f87dbf..1818ecb1b4 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -199,6 +199,7 @@ defineExpose({ open, close }); } .icon { + display: flex; text-align: center; margin-right: var(--spacing-2xs); diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss index 2aaed2df0c..6d1be46ef1 100644 --- a/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss +++ b/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss @@ -520,6 +520,9 @@ --color-menu-background: var(--p-gray-740); --color-menu-hover-background: var(--p-gray-670); --color-menu-active-background: var(--p-gray-670); + + /* Ag Grid */ + --grid-row-selected-background: var(--p-color-secondary-720); } body[data-theme='dark'] { diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.scss index 4b6dd7502d..3d323fcfdc 100644 --- a/packages/frontend/@n8n/design-system/src/css/_tokens.scss +++ b/packages/frontend/@n8n/design-system/src/css/_tokens.scss @@ -686,6 +686,9 @@ // Params --color-icon-base: var(--color-text-light); --color-icon-hover: var(--p-color-primary-320); + + /* Ag Grid */ + --grid-row-selected-background: var(--p-color-secondary-070); } :root { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b1dab74f56..124ddf6bfa 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2834,7 +2834,7 @@ "contextual.users.settings.unavailable.button.cloud": "Upgrade now", "contextual.feature.unavailable.title": "Available on the Enterprise Plan", "contextual.feature.unavailable.title.cloud": "Available on the Pro Plan", - "dataStore.dataStores": "Data Tables", + "dataStore.dataStores": "Data tables", "dataStore.empty.label": "You don't have any data tables yet", "dataStore.empty.description": "Once you create data tables for your projects, they will appear here", "dataStore.empty.button.label": "Create Data Table in \"{projectName}\"", @@ -3004,8 +3004,10 @@ "settings.mfa.updateConfiguration": "MFA configuration updated", "settings.mfa.invalidAuthenticatorCode": "Invalid authenticator code", "projects.header.overview.subtitle": "All the workflows, credentials and executions you have access to", + "projects.header.overview.subtitleWithDataTables": "All the workflows, credentials and data tables you have access to", "projects.header.shared.title": "Shared with you", "projects.header.personal.subtitle": "Workflows and credentials owned by you", + "projects.header.personal.subtitleWithDataTables": "Workflows, credentials and data tables owned by you", "projects.header.shared.subtitle": "Workflows and credentials other users have shared with you", "projects.header.create.workflow": "Create Workflow", "projects.header.create.credential": "Create Credential", diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts index f5bea57e2f..16044d31af 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -123,6 +123,7 @@ describe('ProjectHeader', () => { }); it('Overview: should render the correct title and subtitle', async () => { + settingsStore.isDataStoreFeatureEnabled = false; vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true); const { getByTestId, rerender } = renderComponent(); const overviewSubtitle = 'All the workflows, credentials and executions you have access to'; @@ -146,6 +147,7 @@ describe('ProjectHeader', () => { }); it('Personal: should render the correct title and subtitle', async () => { + settingsStore.isDataStoreFeatureEnabled = false; vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false); vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false); const { getByTestId, rerender } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index 13fa619b4e..f32c94d499 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -241,9 +241,17 @@ const sectionDescription = computed(() => { if (projectPages.isSharedSubPage) { return i18n.baseText('projects.header.shared.subtitle'); } else if (projectPages.isOverviewSubPage) { - return i18n.baseText('projects.header.overview.subtitle'); + return i18n.baseText( + settingsStore.isDataStoreFeatureEnabled + ? 'projects.header.overview.subtitleWithDataTables' + : 'projects.header.overview.subtitle', + ); } else if (isPersonalProject.value) { - return i18n.baseText('projects.header.personal.subtitle'); + return i18n.baseText( + settingsStore.isDataStoreFeatureEnabled + ? 'projects.header.personal.subtitleWithDataTables' + : 'projects.header.personal.subtitle', + ); } return null; diff --git a/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue index 530e8ce7df..4c2140fe3e 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue @@ -1,6 +1,6 @@ + + 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 a94299ab44..90456c6df7 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 @@ -1,6 +1,5 @@ import { createComponentRenderer } from '@/__tests__/render'; import DataStoreTable from '@/features/dataStore/components/dataGrid/DataStoreTable.vue'; -import { fireEvent, waitFor } from '@testing-library/vue'; import { createPinia, setActivePinia } from 'pinia'; import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; import type { DataStore } from '@/features/dataStore/datastore.types'; @@ -44,6 +43,7 @@ vi.mock('ag-grid-community', () => ({ ClientSideRowModelApiModule: {}, ValidationModule: {}, UndoRedoEditModule: {}, + CellStyleModule: {}, })); // Mock the n8n theme @@ -137,47 +137,16 @@ describe('DataStoreTable', () => { }); describe('Component Initialization', () => { - it('should render the component with AG Grid and AddColumnPopover', () => { + it('should render the component with AG Grid', () => { const { getByTestId } = renderComponent(); expect(getByTestId('ag-grid-vue')).toBeInTheDocument(); - expect(getByTestId('add-column-popover')).toBeInTheDocument(); }); it('should render pagination controls', () => { const { getByTestId } = renderComponent(); - expect(getByTestId('data-store-content-pagination')).toBeInTheDocument(); }); - - it('should render add row button', () => { - const { getByTestId } = renderComponent(); - - expect(getByTestId('data-store-add-row-button')).toBeInTheDocument(); - }); - }); - - describe('Add Column Functionality', () => { - it('should handle add column event from AddColumnPopover', async () => { - const { getByTestId } = renderComponent(); - - const addColumnPopover = getByTestId('add-column-popover'); - const addButton = addColumnPopover.querySelector( - '[data-test-id="data-store-add-column-button"]', - ); - - expect(addButton).toBeInTheDocument(); - - await fireEvent.click(addButton!); - - await waitFor(() => { - expect(dataStoreStore.addDataStoreColumn).toHaveBeenCalledWith( - mockDataStore.id, - mockDataStore.projectId, - { name: 'newColumn', type: 'string' }, - ); - }); - }); }); describe('Empty Data Store', () => { 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 05ba1cc611..6245d1b997 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 @@ -38,18 +38,28 @@ import { ClientSideRowModelApiModule, ValidationModule, UndoRedoEditModule, + CellStyleModule, } from 'ag-grid-community'; import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme'; -import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue'; import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue'; import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; import { useI18n } from '@n8n/i18n'; import { useToast } from '@/composables/useToast'; -import { DEFAULT_ID_COLUMN_NAME, EMPTY_VALUE, NULL_VALUE } from '@/features/dataStore/constants'; +import { + DEFAULT_ID_COLUMN_NAME, + EMPTY_VALUE, + NULL_VALUE, + DATA_STORE_ID_COLUMN_WIDTH, + DATA_STORE_HEADER_HEIGHT, + DATA_STORE_ROW_HEIGHT, + ADD_ROW_ROW_ID, +} from '@/features/dataStore/constants'; import { useMessage } from '@/composables/useMessage'; import { MODAL_CONFIRM } from '@/constants'; import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue'; import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes'; +import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue'; +import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.vue'; import { isDataStoreValue } from '@/features/dataStore/typeGuards'; import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue'; import { onClickOutside } from '@vueuse/core'; @@ -68,6 +78,7 @@ ModuleRegistry.registerModules([ DateEditorModule, ClientSideRowModelApiModule, UndoRedoEditModule, + CellStyleModule, ]); type Props = { @@ -83,7 +94,7 @@ const emit = defineEmits<{ const i18n = useI18n(); const toast = useToast(); const message = useMessage(); -const { getDefaultValueForType, mapToAGCellType } = useDataStoreTypes(); +const { mapToAGCellType } = useDataStoreTypes(); const dataStoreStore = useDataStoreStore(); @@ -94,7 +105,8 @@ const rowData = ref([]); const rowSelection: RowSelectionOptions | 'single' | 'multiple' = { mode: 'multiRow', enableClickSelection: false, - checkboxes: true, + checkboxes: (params) => params.data?.id !== ADD_ROW_ROW_ID, + isRowSelectable: (params) => params.data?.id !== ADD_ROW_ROW_ID, }; const contentLoading = ref(false); @@ -106,13 +118,6 @@ const isTextEditorOpen = ref(false); const gridContainer = useTemplateRef('gridContainer'); -// Shared config for all columns -const defaultColumnDef: ColDef = { - flex: 1, - sortable: false, - filter: false, -}; - // Pagination const pageSizeOptions = [10, 20, 50]; const currentPage = ref(1); @@ -132,7 +137,12 @@ const onGridReady = (params: GridReadyEvent) => { const refreshGridData = () => { if (gridApi.value) { gridApi.value.setGridOption('columnDefs', colDefs.value); - gridApi.value.setGridOption('rowData', rowData.value); + gridApi.value.setGridOption('rowData', [ + ...rowData.value, + { + id: ADD_ROW_ROW_ID, + }, + ]); } }; @@ -189,7 +199,7 @@ const onDeleteColumn = async (columnId: string) => { } }; -const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) => { +const onAddColumn = async (column: DataStoreColumnCreatePayload) => { try { const newColumn = await dataStoreStore.addDataStoreColumn( props.dataStore.id, @@ -199,9 +209,13 @@ const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) if (!newColumn) { throw new Error(i18n.baseText('generic.unknownError')); } - colDefs.value = [...colDefs.value, createColumnDef(newColumn)]; + colDefs.value = [ + ...colDefs.value.slice(0, -1), + createColumnDef(newColumn), + ...colDefs.value.slice(-1), + ]; rowData.value = rowData.value.map((row) => { - return { ...row, [newColumn.name]: getDefaultValueForType(newColumn.type) }; + return { ...row, [newColumn.name]: null }; }); refreshGridData(); } catch (error) { @@ -214,13 +228,24 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial = {}) colId: col.id, field: col.name, headerName: col.name, - editable: true, + sortable: false, + flex: 1, + editable: (params) => params.data?.id !== ADD_ROW_ROW_ID, resizable: true, lockPinned: true, headerComponent: ColumnHeader, cellEditorPopup: false, headerComponentParams: { onDelete: onDeleteColumn }, cellDataType: mapToAGCellType(col.type), + cellClass: (params) => { + if (params.data?.id === ADD_ROW_ROW_ID) { + return 'add-row-cell'; + } + if (params.column.getUserProvidedColDef()?.cellDataType === 'boolean') { + return 'boolean-cell'; + } + return ''; + }, valueGetter: (params: ValueGetterParams) => { // If the value is null, return null to show empty cell if (params.data?.[col.name] === null || params.data?.[col.name] === undefined) { @@ -236,6 +261,9 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial = {}) return params.data?.[col.name]; }, cellRendererSelector: (params: ICellRendererParams) => { + if (params.data?.id === ADD_ROW_ROW_ID || col.id === 'add-column') { + return {}; + } let rowValue = params.data?.[col.name]; // When adding new column, rowValue is undefined (same below, in string cell editor) if (rowValue === undefined) { @@ -289,7 +317,7 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial = {}) // Setup date editor if (col.type === 'date') { columnDef.cellEditor = 'agDateCellEditor'; - columnDef.cellEditorPopup = true; + columnDef.cellEditorPopup = false; } return { ...columnDef, @@ -324,8 +352,8 @@ const onColumnMoved = async (moveEvent: ColumnMovedEvent) => { const onAddRowClick = async () => { try { // Go to last page if we are not there already - if (currentPage.value * pageSize.value < totalItems.value) { - await setCurrentPage(Math.ceil(totalItems.value / pageSize.value)); + if (currentPage.value * pageSize.value < totalItems.value + 1) { + await setCurrentPage(Math.ceil((totalItems.value + 1) / pageSize.value)); } contentLoading.value = true; emit('toggleSave', true); @@ -335,7 +363,9 @@ const onAddRowClick = async () => { props.dataStore.columns.forEach((col) => { newRow[col.name] = null; }); - rows.value.push(newRow); + rowData.value.push(newRow); + totalItems.value += 1; + refreshGridData(); } catch (error) { toast.showError(error, i18n.baseText('dataStore.addRow.error')); } finally { @@ -360,10 +390,42 @@ const initColumnDefinitions = () => { 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(props.dataStore.columns, 'index').map((col) => createColumnDef(col)), + createColumnDef( + { + index: props.dataStore.columns.length + 1, + id: 'add-column', + name: 'Add Column', + type: 'string', + }, + { + editable: false, + suppressMovable: true, + lockPinned: true, + lockPosition: 'right', + resizable: false, + headerComponent: AddColumnButton, + headerComponentParams: { onAddColumn }, + }, + ), ]; }; @@ -404,8 +466,11 @@ const onCellClicked = (params: CellClickedEvent) => { const clickedCellColumn = params.column.getColId(); const clickedCellRow = params.rowIndex; - // Skip if rowIndex is null - if (clickedCellRow === null) return; + 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 = @@ -437,10 +502,9 @@ const fetchDataStoreContent = async () => { currentPage.value, pageSize.value, ); - rows.value = fetchedRows.data; + rowData.value = fetchedRows.data; totalItems.value = fetchedRows.count; - rowData.value = rows.value; - + refreshGridData(); handleClearSelection(); } catch (error) { toast.showError(error, i18n.baseText('dataStore.fetchContent.error')); @@ -544,6 +608,11 @@ const handleClearSelection = () => { gridApi.value.deselectAll(); } }; + +defineExpose({ + addRow: onAddRowClick, + addColumn: onAddColumn, +});