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 () => { - + { - + @@ -108,7 +93,7 @@ onMounted(async () => { } .header-loading { - margin-bottom: var(--spacing-xl); + margin-bottom: var(--spacing-2xl); div { height: 2em; diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreBreadcrumbs.vue b/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreBreadcrumbs.vue index 64d94d82ad..1b08942faf 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreBreadcrumbs.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreBreadcrumbs.vue @@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n'; import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; import { useRouter } from 'vue-router'; import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue'; -import { DATA_STORE_VIEW } from '@/features/dataStore/constants'; +import { PROJECT_DATA_STORES } from '@/features/dataStore/constants'; import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; import { useToast } from '@/composables/useToast'; @@ -51,7 +51,10 @@ const onItemClicked = async (item: PathItem) => { }; const onDelete = async () => { - await router.push({ name: DATA_STORE_VIEW, params: { projectId: props.dataStore.projectId } }); + await router.push({ + name: PROJECT_DATA_STORES, + params: { projectId: props.dataStore.projectId }, + }); }; const onRename = async () => { diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.test.ts b/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.test.ts index cdd0b49e27..3e21a3cfb7 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.test.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.test.ts @@ -49,7 +49,17 @@ const renderComponent = createComponentRenderer(DataStoreCard, { global: { stubs: { N8nLink: { - template: '', + template: '', + props: ['to'], + computed: { + href() { + // Generate href from the route object + if (this.to && typeof this.to === 'object') { + return `/projects/${this.to.params.projectId}/datastores/${this.to.params.id}`; + } + return '#'; + }, + }, }, TimeAgo: { template: 'just now', @@ -98,29 +108,23 @@ describe('DataStoreCard', () => { const wrapper = renderComponent(); const link = wrapper.getByTestId('data-store-card-link'); expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute( + 'href', + `/projects/${DEFAULT_DATA_STORE.projectId}/datastores/${DEFAULT_DATA_STORE.id}`, + ); }); it('should display record count information', () => { const { getByTestId } = renderComponent(); const recordCountElement = getByTestId('data-store-card-record-count'); expect(recordCountElement).toBeInTheDocument(); + expect(recordCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.recordCount}`); }); it('should display column count information', () => { const { getByTestId } = renderComponent(); const columnCountElement = getByTestId('data-store-card-column-count'); expect(columnCountElement).toBeInTheDocument(); - }); - - it('should display last updated information', () => { - const { getByTestId } = renderComponent(); - const lastUpdatedElement = getByTestId('data-store-card-last-updated'); - expect(lastUpdatedElement).toBeInTheDocument(); - }); - - it('should display created information', () => { - const { getByTestId } = renderComponent(); - const createdElement = getByTestId('data-store-card-created'); - expect(createdElement).toBeInTheDocument(); + expect(columnCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.columns.length + 1}`); }); }); diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.vue b/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.vue index 17d2591001..6d676e85c0 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/components/DataStoreCard.vue @@ -110,7 +110,7 @@ const onNameSubmit = (name: string) => { > {{ i18n.baseText('dataStore.card.column.count', { - interpolate: { count: props.dataStore.columns.length }, + interpolate: { count: props.dataStore.columns.length + 1 }, }) }} diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.test.ts b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.test.ts new file mode 100644 index 0000000000..2fdd75688d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.test.ts @@ -0,0 +1,305 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue'; +import { fireEvent, waitFor } from '@testing-library/vue'; +import { createPinia, setActivePinia } from 'pinia'; +import { MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants'; + +vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({ + useDataStoreTypes: () => ({ + getIconForType: (type: string) => { + const iconMap: Record = { + string: 'abc', + number: '123', + boolean: 'toggle-off', + date: 'calendar', + }; + return iconMap[type] || 'abc'; + }, + }), +})); + +vi.mock('@/composables/useDebounce', () => ({ + useDebounce: () => ({ + debounce: (fn: Function) => fn, + }), +})); + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + baseText: (key: string) => { + const translations: Record = { + 'dataStore.addColumn.label': 'Add column', + 'dataStore.addColumn.nameInput.label': 'Column name', + 'dataStore.addColumn.nameInput.placeholder': 'Enter column name', + 'dataStore.addColumn.typeInput.label': 'Column type', + 'dataStore.addColumn.invalidName.error': 'Invalid column name', + 'dataStore.addColumn.invalidName.description': + 'Column names must start with a letter and contain only letters, numbers, and hyphens', + }; + return translations[key] || key; + }, + }), +})); + +describe('AddColumnPopover', () => { + const renderComponent = createComponentRenderer(AddColumnPopover); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('should render the add column button', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId('data-store-add-column-trigger-button')).toBeInTheDocument(); + }); + + it('should focus name input when popover opens', async () => { + const { getByTestId, getByPlaceholderText } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + await waitFor(() => { + const nameInput = getByPlaceholderText('Enter column name'); + expect(nameInput).toHaveFocus(); + }); + }); + + it('should emit addColumn event with correct payload', async () => { + const { getByTestId, getByPlaceholderText, emitted } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + await fireEvent.update(nameInput, 'newColumn'); + + const submitButton = getByTestId('data-store-add-column-submit-button'); + expect(submitButton).not.toBeDisabled(); + await fireEvent.click(submitButton); + + expect(emitted().addColumn).toBeTruthy(); + expect(emitted().addColumn[0]).toEqual([ + { + column: { + name: 'newColumn', + type: 'string', + }, + }, + ]); + }); + + it('should disable submit button when name is empty', async () => { + const { getByTestId } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + await waitFor(() => { + const submitButton = getByTestId('data-store-add-column-submit-button'); + expect(submitButton).toBeDisabled(); + }); + }); + + it('should show error for invalid column names', async () => { + const { getByPlaceholderText, getByText, getByTestId } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + + // Test invalid name starting with hyphen + await fireEvent.update(nameInput, '-invalid'); + await fireEvent.blur(nameInput); + + await waitFor(() => { + expect(getByText('Invalid column name')).toBeInTheDocument(); + const submitButton = getByTestId('data-store-add-column-submit-button'); + expect(submitButton).toBeDisabled(); + }); + }); + + it('should allow valid column names', async () => { + const { getByTestId, getByPlaceholderText, queryByText } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + + // Test valid names + const validNames = ['column1', 'my-column', 'Column123', 'a1b2c3']; + + for (const name of validNames) { + await fireEvent.update(nameInput, name); + await fireEvent.blur(nameInput); + + await waitFor(() => { + expect(queryByText('Invalid column name')).not.toBeInTheDocument(); + }); + } + }); + + it('should clear error when correcting invalid name', async () => { + const { getByTestId, getByPlaceholderText, getByText, queryByText } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + + // Enter invalid name + await fireEvent.update(nameInput, '-invalid'); + await fireEvent.blur(nameInput); + + await waitFor(() => { + expect(getByText('Invalid column name')).toBeInTheDocument(); + }); + + // Correct the name + await fireEvent.update(nameInput, 'valid'); + await fireEvent.blur(nameInput); + + await waitFor(() => { + expect(queryByText('Invalid column name')).not.toBeInTheDocument(); + }); + }); + + it('should respect max column name length', async () => { + const { getByTestId, getByPlaceholderText } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement; + + expect(nameInput.maxLength).toBe(MAX_COLUMN_NAME_LENGTH); + }); + + it('should allow selecting different column types', async () => { + const { getByPlaceholderText, getByRole, getByText, getByTestId, emitted } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + await fireEvent.update(nameInput, 'numberColumn'); + + // Click on the select to open dropdown + const selectElement = getByRole('combobox'); + await fireEvent.click(selectElement); + + // Select 'number' type + const numberOption = getByText('number'); + await fireEvent.click(numberOption); + + const submitButton = getByTestId('data-store-add-column-submit-button'); + await fireEvent.click(submitButton); + + expect(emitted().addColumn).toBeTruthy(); + expect(emitted().addColumn[0]).toEqual([ + { + column: { + name: 'numberColumn', + type: 'number', + }, + }, + ]); + }); + + it('should reset form after successful submission', async () => { + const { getByPlaceholderText, getByTestId } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement; + await fireEvent.update(nameInput, 'testColumn'); + + const submitButton = getByTestId('data-store-add-column-submit-button'); + await fireEvent.click(submitButton); + + // Click button again to open popover + await fireEvent.click(addButton); + + await waitFor(() => { + const resetNameInput = getByPlaceholderText('Enter column name') as HTMLInputElement; + expect(resetNameInput.value).toBe(''); + }); + }); + + it('should close popover after successful submission', async () => { + const { getByPlaceholderText, getByTestId, queryByText } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + await fireEvent.update(nameInput, 'testColumn'); + + const submitButton = getByTestId('data-store-add-column-submit-button'); + await fireEvent.click(submitButton); + + await waitFor(() => { + expect(queryByText('Column name')).not.toBeInTheDocument(); + }); + }); + + it('should allow submission with Enter key', async () => { + const { getByTestId, getByPlaceholderText, emitted } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + await fireEvent.update(nameInput, 'enterColumn'); + await fireEvent.keyUp(nameInput, { key: 'Enter' }); + + expect(emitted().addColumn).toBeTruthy(); + expect(emitted().addColumn[0]).toEqual([ + { + column: { + name: 'enterColumn', + type: 'string', + }, + }, + ]); + }); + + it('should display all column type options', async () => { + const { getByTestId, getByRole, getByText } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const selectElement = getByRole('combobox'); + await fireEvent.click(selectElement); + + await waitFor(() => { + expect(getByText('string')).toBeInTheDocument(); + expect(getByText('number')).toBeInTheDocument(); + expect(getByText('boolean')).toBeInTheDocument(); + expect(getByText('date')).toBeInTheDocument(); + }); + }); + + it('should show tooltip with error description', async () => { + const { getByPlaceholderText, getByText, getByTestId } = renderComponent(); + const addButton = getByTestId('data-store-add-column-trigger-button'); + + await fireEvent.click(addButton); + + const nameInput = getByPlaceholderText('Enter column name'); + await fireEvent.update(nameInput, '-invalid'); + await fireEvent.blur(nameInput); + + await waitFor(() => { + expect(getByText('Invalid column name')).toBeInTheDocument(); + // Check for help icon that shows tooltip + const helpIcon = getByTestId('add-column-error-help-icon'); + expect(helpIcon).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.vue b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.vue new file mode 100644 index 0000000000..97d27d6595 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/AddColumnPopover.vue @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + {{ error }} + + + + + + + + + + + + {{ type }} + + + + + + {{ i18n.baseText('dataStore.addColumn.label') }} + + + + + + + + + + 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 new file mode 100644 index 0000000000..f95853f806 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts @@ -0,0 +1,179 @@ +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'; + +// Mock ag-grid-vue3 +interface MockComponentInstance { + $emit: (event: string, payload: unknown) => void; +} + +vi.mock('ag-grid-vue3', () => ({ + AgGridVue: { + name: 'AgGridVue', + template: '', + props: ['rowData', 'columnDefs', 'defaultColDef', 'domLayout', 'animateRows', 'theme'], + emits: ['gridReady'], + mounted(this: MockComponentInstance) { + this.$emit('gridReady', { + api: { + // Mock API methods + }, + }); + }, + }, +})); + +// Mock ag-grid-community modules +vi.mock('ag-grid-community', () => ({ + ModuleRegistry: { + registerModules: vi.fn(), + }, + ClientSideRowModelModule: {}, + TextEditorModule: {}, + LargeTextEditorModule: {}, + ColumnAutoSizeModule: {}, + CheckboxEditorModule: {}, + NumberEditorModule: {}, +})); + +// Mock the n8n theme +vi.mock('@/features/dataStore/components/dataGrid/n8nTheme', () => ({ + n8nTheme: 'n8n-theme', +})); + +// Mock AddColumnPopover +vi.mock('@/features/dataStore/components/dataGrid/AddColumnPopover.vue', () => ({ + default: { + name: 'AddColumnPopover', + template: + 'Add Column', + props: ['dataStore'], + emits: ['add-column'], + }, +})); + +// Mock composables +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + }), +})); + +vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({ + useDataStoreTypes: () => ({ + mapToAGCellType: (type: string) => { + const typeMap: Record = { + string: 'text', + number: 'number', + boolean: 'boolean', + date: 'date', + }; + return typeMap[type] || 'text'; + }, + }), +})); + +const mockDataStore: DataStore = { + id: 'test-datastore-1', + name: 'Test DataStore', + projectId: 'project-1', + columns: [ + { id: 'col1', name: 'firstName', type: 'string', index: 1 }, + { id: 'col2', name: 'age', type: 'number', index: 2 }, + { id: 'col3', name: 'isActive', type: 'boolean', index: 3 }, + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + sizeBytes: 0, + recordCount: 0, +}; + +describe('DataStoreTable', () => { + const renderComponent = createComponentRenderer(DataStoreTable, { + props: { + dataStore: mockDataStore, + }, + }); + + let dataStoreStore: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + dataStoreStore = useDataStoreStore(); + dataStoreStore.addDataStoreColumn = vi.fn().mockResolvedValue({ + id: 'new-col', + name: 'newColumn', + type: 'string', + index: 4, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Component Initialization', () => { + it('should render the component with AG Grid and AddColumnPopover', () => { + 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', () => { + it('should show grid for empty data store', () => { + const emptyDataStore: DataStore = { + ...mockDataStore, + columns: [], + }; + + const { getByTestId } = renderComponent({ + props: { + dataStore: emptyDataStore, + }, + }); + + expect(getByTestId('ag-grid-vue')).toBeInTheDocument(); + }); + }); +}); 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 new file mode 100644 index 0000000000..01c44860a8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/n8nTheme.ts b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/n8nTheme.ts new file mode 100644 index 0000000000..d0b90a779e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/n8nTheme.ts @@ -0,0 +1,9 @@ +import { iconSetAlpine, themeQuartz } from 'ag-grid-community'; + +export const n8nTheme = themeQuartz.withPart(iconSetAlpine).withParams({ + columnBorder: true, + rowBorder: true, + rowVerticalPaddingScale: 0.8, + sidePanelBorder: true, + wrapperBorder: true, +}); diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts new file mode 100644 index 0000000000..dfe57b3d19 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts @@ -0,0 +1,34 @@ +import type { IconName } from '@n8n/design-system/components/N8nIcon/icons'; +import type { AGGridCellType, DataStoreColumnType } from '@/features/dataStore/datastore.types'; + +/* eslint-disable id-denylist */ +const COLUMN_TYPE_ICONS: Record = { + string: 'type', + number: 'hash', + boolean: 'toggle-right', + date: 'calendar', +} as const; +/* eslint-enable id-denylist */ + +export const useDataStoreTypes = () => { + const getIconForType = (type: DataStoreColumnType) => COLUMN_TYPE_ICONS[type]; + + /** + * Maps a DataStoreColumnType to an AGGridCellType. + * For now the only mismatch is our 'string' type, + * which needs to be mapped manually. + * @param colType The DataStoreColumnType to map. + * @returns The corresponding AGGridCellType. + */ + const mapToAGCellType = (colType: DataStoreColumnType): AGGridCellType => { + if (colType === 'string') { + return 'text'; + } + return colType; + }; + + return { + getIconForType, + mapToAGCellType, + }; +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/constants.ts b/packages/frontend/editor-ui/src/features/dataStore/constants.ts index 2be548d093..afe8c8f8c2 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/constants.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/constants.ts @@ -13,3 +13,9 @@ export const DATA_STORE_CARD_ACTIONS = { }; export const ADD_DATA_STORE_MODAL_KEY = 'addDataStoreModal'; + +export const DEFAULT_ID_COLUMN_NAME = 'id'; + +export const MAX_COLUMN_NAME_LENGTH = 128; + +export const COLUMN_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts index 1f451cc327..1c7e4a8b04 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts @@ -1,7 +1,11 @@ import { makeRestApiRequest } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; -import { type DataStore } from '@/features/dataStore/datastore.types'; +import type { + DataStoreColumnCreatePayload, + DataStore, + DataStoreColumn, +} from '@/features/dataStore/datastore.types'; export const fetchDataStoresApi = async ( context: IRestApiContext, @@ -32,6 +36,7 @@ export const createDataStoreApi = async ( context: IRestApiContext, name: string, projectId?: string, + columns?: DataStoreColumnCreatePayload[], ) => { return await makeRestApiRequest( context, @@ -39,7 +44,7 @@ export const createDataStoreApi = async ( `/projects/${projectId}/data-stores`, { name, - columns: [], + columns: columns ?? [], }, ); }; @@ -75,3 +80,19 @@ export const updateDataStoreApi = async ( }, ); }; + +export const addDataStoreColumnApi = async ( + context: IRestApiContext, + dataStoreId: string, + projectId: string, + column: DataStoreColumnCreatePayload, +) => { + return await makeRestApiRequest( + context, + 'POST', + `/projects/${projectId}/data-stores/${dataStoreId}/columns`, + { + ...column, + }, + ); +}; 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 857a3187b4..c3b94f36ae 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts @@ -7,8 +7,9 @@ import { createDataStoreApi, deleteDataStoreApi, updateDataStoreApi, + addDataStoreColumnApi, } from '@/features/dataStore/dataStore.api'; -import type { DataStore } from '@/features/dataStore/datastore.types'; +import type { DataStore, DataStoreColumnCreatePayload } from '@/features/dataStore/datastore.types'; import { useProjectsStore } from '@/stores/projects.store'; export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { @@ -83,6 +84,26 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { return await fetchDataStoreDetails(datastoreId, projectId); }; + const addDataStoreColumn = async ( + datastoreId: string, + projectId: string, + column: DataStoreColumnCreatePayload, + ) => { + const newColumn = await addDataStoreColumnApi( + rootStore.restApiContext, + datastoreId, + projectId, + column, + ); + if (newColumn) { + const index = dataStores.value.findIndex((store) => store.id === datastoreId); + if (index !== -1) { + dataStores.value[index].columns.push(newColumn); + } + } + return newColumn; + }; + return { dataStores, totalCount, @@ -92,5 +113,6 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { updateDataStore, fetchDataStoreDetails, fetchOrFindDataStore, + addDataStoreColumn, }; }); diff --git a/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts b/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts index 86cdb4eb94..4c3fbe978a 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts @@ -8,13 +8,23 @@ export type DataStore = { columns: DataStoreColumn[]; createdAt: string; updatedAt: string; - projectId?: string; + projectId: string; project?: Project; }; +export type DataStoreColumnType = 'string' | 'number' | 'boolean' | 'date'; + +export type AGGridCellType = 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object'; + export type DataStoreColumn = { id: string; name: string; - type: string; + type: DataStoreColumnType; index: number; }; + +export type DataStoreColumnCreatePayload = Pick; + +export type DataStoreValue = string | number | boolean | Date | null; + +export type DataStoreRow = Record; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e78dd2803..2c2d653abf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2523,6 +2523,9 @@ importers: '@vueuse/core': specifier: catalog:frontend version: 10.11.0(vue@3.5.13(typescript@5.9.2)) + ag-grid-vue3: + specifier: ^34.1.1 + version: 34.1.1(vue@3.5.13(typescript@5.9.2)) array.prototype.tosorted: specifier: 1.1.4 version: 1.1.4 @@ -8140,6 +8143,17 @@ packages: resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==} engines: {node: '>=6.0'} + ag-charts-types@12.1.1: + resolution: {integrity: sha512-VbAOfp1E7+Z/TBufJOIjUYdx70kQRDg49WoluiVKVSB0r0el/uvxARp9xjzx4ByBq9+Xq/V23tJVsRj1MS2A/g==} + + ag-grid-community@34.1.1: + resolution: {integrity: sha512-ODVvGoMTkyGvMT8b5lzvum5r93bG6CKdJdNrk6u/aYS7oqZ5rUEXJJHC8n8Zq+o76KhFiXMBQrU39xuhz8i+Tg==} + + ag-grid-vue3@34.1.1: + resolution: {integrity: sha512-+O3nDlInu4RZJ82zkqVD/DCfps0OsfO1MUi453zEJ+CbJudhq+l4f9hy+l+OYWH10XOEdr6+HL9j6toONWyS8g==} + peerDependencies: + vue: ^3.5.0 + agent-base@5.1.1: resolution: {integrity: sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==} engines: {node: '>= 6.0.0'} @@ -23079,6 +23093,17 @@ snapshots: adm-zip@0.5.10: {} + ag-charts-types@12.1.1: {} + + ag-grid-community@34.1.1: + dependencies: + ag-charts-types: 12.1.1 + + ag-grid-vue3@34.1.1(vue@3.5.13(typescript@5.9.2)): + dependencies: + ag-grid-community: 34.1.1 + vue: 3.5.13(typescript@5.9.2) + agent-base@5.1.1: {} agent-base@6.0.2: @@ -25523,7 +25548,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) is-core-module: 2.16.1 resolve: 1.22.10 transitivePeerDependencies: @@ -25547,7 +25572,7 @@ snapshots: eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.3)(eslint@9.29.0(jiti@1.21.7)): dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2) eslint: 9.29.0(jiti@1.21.7) @@ -25586,7 +25611,7 @@ snapshots: array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 eslint: 9.29.0(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 @@ -26545,7 +26570,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 7.0.6 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -29950,7 +29975,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -30938,7 +30963,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color