From 55776f5cf43ff1aa4334254f126efe64627e0222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Mon, 18 Aug 2025 10:09:56 +0200 Subject: [PATCH] feat(editor): Add data store grid component (no-changelog) (#18392) --- .../src/components/N8nIcon/icons.ts | 4 + .../frontend/@n8n/i18n/src/locales/en.json | 10 +- packages/frontend/editor-ui/package.json | 1 + .../dataStore/DataStoreDetailsView.test.ts | 185 +++++++++++ .../dataStore/DataStoreDetailsView.vue | 23 +- .../components/DataStoreBreadcrumbs.vue | 7 +- .../components/DataStoreCard.test.ts | 30 +- .../dataStore/components/DataStoreCard.vue | 2 +- .../dataGrid/AddColumnPopover.test.ts | 305 ++++++++++++++++++ .../components/dataGrid/AddColumnPopover.vue | 208 ++++++++++++ .../dataGrid/DataStoreTable.test.ts | 179 ++++++++++ .../components/dataGrid/DataStoreTable.vue | 245 ++++++++++++++ .../dataStore/components/dataGrid/n8nTheme.ts | 9 + .../composables/useDataStoreTypes.ts | 34 ++ .../src/features/dataStore/constants.ts | 6 + .../src/features/dataStore/dataStore.api.ts | 25 +- .../src/features/dataStore/dataStore.store.ts | 24 +- .../src/features/dataStore/datastore.types.ts | 14 +- pnpm-lock.yaml | 37 ++- 19 files changed, 1301 insertions(+), 47 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.test.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.vue create mode 100644 packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue create mode 100644 packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/n8nTheme.ts create mode 100644 packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 54a1bd84ca..17bc277cfc 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -179,9 +179,11 @@ import IconLucideTags from '~icons/lucide/tags'; import IconLucideTerminal from '~icons/lucide/terminal'; import IconLucideThumbsDown from '~icons/lucide/thumbs-down'; import IconLucideThumbsUp from '~icons/lucide/thumbs-up'; +import IconLucideToggleRight from '~icons/lucide/toggle-right'; import IconLucideTrash2 from '~icons/lucide/trash-2'; import IconLucideTreePine from '~icons/lucide/tree-pine'; import IconLucideTriangleAlert from '~icons/lucide/triangle-alert'; +import IconLucideType from '~icons/lucide/type'; import IconLucideUndo2 from '~icons/lucide/undo-2'; import IconLucideUnlink from '~icons/lucide/unlink'; import IconLucideUser from '~icons/lucide/user'; @@ -593,6 +595,8 @@ export const updatedIconSet = { 'trash-2': IconLucideTrash2, 'tree-pine': IconLucideTreePine, 'triangle-alert': IconLucideTriangleAlert, + type: IconLucideType, + 'toggle-right': IconLucideToggleRight, 'undo-2': IconLucideUndo2, unlink: IconLucideUnlink, user: IconLucideUser, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 422d8fdf27..f1b8ebed14 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -19,7 +19,8 @@ "activate": "Activate", "user": "User", "enabled": "Enabled", - "disabled": "Disabled" + "disabled": "Disabled", + "type": "Type" }, "_reusableDynamicText": { "readMore": "Read more", @@ -2858,6 +2859,13 @@ "dataStore.noColumns.heading": "No columns yet", "dataStore.noColumns.description": "Add columns to start storing data in this data store.", "dataStore.noColumns.button.label": "Add first column", + "dataStore.addColumn.label": "Add Column", + "dataStore.addColumn.nameInput.label": "@:_reusableBaseText.name", + "dataStore.addColumn.nameInput.placeholder": "Enter column name", + "dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type", + "dataStore.addColumn.error": "Error adding column", + "dataStore.addColumn.invalidName.error": "Invalid column name", + "dataStore.addColumn.invalidName.description": "Only alphanumeric characters and non-leading dashes are allowed for column names", "settings.ldap": "LDAP", "settings.ldap.note": "LDAP allows users to authenticate with their centralized account. It's compatible with services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.", "settings.ldap.infoTip": "Learn more about LDAP in the Docs", diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 9fcf292b68..246124e032 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -57,6 +57,7 @@ "@vue-flow/node-resizer": "^1.4.0", "@vueuse/components": "^10.11.0", "@vueuse/core": "catalog:frontend", + "ag-grid-vue3": "^34.1.1", "array.prototype.tosorted": "1.1.4", "axios": "catalog:", "bowser": "2.11.0", diff --git a/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.test.ts b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.test.ts new file mode 100644 index 0000000000..9f72bdde48 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.test.ts @@ -0,0 +1,185 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import DataStoreDetailsView from '@/features/dataStore/DataStoreDetailsView.vue'; +import { createTestingPinia } from '@pinia/testing'; +import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; +import { useToast } from '@/composables/useToast'; +import { useRouter } from 'vue-router'; +import type { DataStore } from '@/features/dataStore/datastore.types'; +import { waitFor } from '@testing-library/vue'; + +vi.mock('@/composables/useToast'); +vi.mock('vue-router'); +vi.mock('@/composables/useDocumentTitle', () => ({ + useDocumentTitle: vi.fn(() => ({ + set: vi.fn(), + })), +})); +vi.mock('@n8n/i18n', () => { + const baseText = (key: string) => { + const translations: Record = { + 'dataStore.getDetails.error': 'Error fetching data store details', + 'dataStore.notFound': 'Data store not found', + 'dataStore.dataStores': 'Data Stores', + }; + return translations[key] || key; + }; + return { + useI18n: () => ({ baseText }), + i18n: { baseText }, + i18nInstance: { + global: { + t: baseText, + te: () => true, + }, + }, + }; +}); + +const mockRouter = { + push: vi.fn(), +}; + +const mockToast = { + showError: vi.fn(), +}; + +const DEFAULT_DATA_STORE: DataStore = { + id: 'ds1', + name: 'Test Data Store', + sizeBytes: 2048, + recordCount: 50, + columns: [ + { id: '1', name: 'id', type: 'string', index: 0 }, + { id: '2', name: 'name', type: 'string', index: 1 }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + projectId: 'proj1', +}; + +const renderComponent = createComponentRenderer(DataStoreDetailsView, { + props: { + id: 'ds1', + projectId: 'proj1', + }, + global: { + stubs: { + DataStoreBreadcrumbs: true, + DataStoreTable: true, + }, + }, +}); + +describe('DataStoreDetailsView', () => { + beforeEach(() => { + (useToast as ReturnType).mockReturnValue(mockToast); + (useRouter as ReturnType).mockReturnValue(mockRouter); + vi.clearAllMocks(); + }); + + describe('Loading states', () => { + it('should show loading state initially', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const dataStoreStore = useDataStoreStore(); + vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockImplementation( + async () => await new Promise(() => {}), + ); + + const { getByTestId } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByTestId('data-store-details-loading')).toBeInTheDocument(); + }); + }); + + it('should hide loading state after successful data fetch', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const dataStoreStore = useDataStoreStore(); + vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE); + + const { queryByTestId } = renderComponent({ pinia }); + + await waitFor(() => { + expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument(); + }); + }); + + it('should hide loading state after error', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const dataStoreStore = useDataStoreStore(); + vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(new Error('Failed')); + + const { queryByTestId } = renderComponent({ pinia }); + + await waitFor(() => { + expect(mockToast.showError).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Data rendering', () => { + it('should render breadcrumbs and table when data is loaded', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const dataStoreStore = useDataStoreStore(); + vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE); + + const { container } = renderComponent({ pinia }); + + await waitFor(() => { + expect(container.querySelector('data-store-breadcrumbs-stub')).toBeInTheDocument(); + expect(container.querySelector('data-store-table-stub')).toBeInTheDocument(); + }); + }); + + it('should not render content when data store is null', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const dataStoreStore = useDataStoreStore(); + vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null); + + const { container } = renderComponent({ pinia }); + + await waitFor(() => { + expect(mockToast.showError).toHaveBeenCalled(); + }); + + expect(container.querySelector('data-store-breadcrumbs-stub')).not.toBeInTheDocument(); + expect(container.querySelector('data-store-table-stub')).not.toBeInTheDocument(); + }); + }); + + describe('Error handling', () => { + it('should show error and redirect when data store not found', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const dataStoreStore = useDataStoreStore(); + vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null); + + renderComponent({ pinia }); + + await waitFor(() => { + expect(mockToast.showError).toHaveBeenCalled(); + expect(mockRouter.push).toHaveBeenCalled(); + }); + }); + + it('should handle API errors', async () => { + const pinia = createTestingPinia({ stubActions: false }); + const dataStoreStore = useDataStoreStore(); + const error = new Error('API Error'); + vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(error); + + renderComponent({ pinia }); + + await waitFor(() => { + expect(mockToast.showError).toHaveBeenCalledWith( + error, + 'Error fetching data store details', + ); + expect(mockRouter.push).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue index 271e601267..6d34f46e53 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue @@ -8,6 +8,7 @@ import { useRouter } from 'vue-router'; import { DATA_STORE_VIEW } from '@/features/dataStore/constants'; import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue'; import { useDocumentTitle } from '@/composables/useDocumentTitle'; +import DataStoreTable from './components/dataGrid/DataStoreTable.vue'; type Props = { id: string; @@ -50,14 +51,6 @@ const initialize = async () => { } }; -const onAddColumnClick = () => { - toast.showMessage({ - type: 'warning', - message: 'Coming soon', - duration: 3000, - }); -}; - onMounted(async () => { documentTitle.set(i18n.baseText('dataStore.dataStores')); await initialize(); @@ -66,7 +59,7 @@ onMounted(async () => {