From 67352ce2f8a331b03ff13179babf3bd5f94969db Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Mon, 25 Aug 2025 14:59:16 +0200 Subject: [PATCH] feat(editor): Implement delete datastore row UI (no-changelog) (#18729) --- .../frontend/@n8n/i18n/src/locales/en.json | 8 +- .../common/SelectedItemsInfo.test.ts | 61 ++++++++++++++ .../components/common/SelectedItemsInfo.vue | 81 +++++++++++++++++++ .../global/GlobalExecutionsList.test.ts | 23 +++++- .../global/GlobalExecutionsList.vue | 51 ++---------- .../components/dataGrid/DataStoreTable.vue | 77 +++++++++++++++++- .../features/dataStore/dataStore.api.test.ts | 40 +++++++++ .../src/features/dataStore/dataStore.api.ts | 16 ++++ .../dataStore/dataStore.store.test.ts | 18 +++++ .../src/features/dataStore/dataStore.store.ts | 6 ++ 10 files changed, 329 insertions(+), 52 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/common/SelectedItemsInfo.test.ts create mode 100644 packages/frontend/editor-ui/src/components/common/SelectedItemsInfo.vue create mode 100644 packages/frontend/editor-ui/src/features/dataStore/dataStore.api.test.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d27f49d3d2..b1dab74f56 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -110,6 +110,8 @@ "generic.unknownError": "An unknown error occurred", "generic.upgradeToEnterprise": "Upgrade to Enterprise", "generic.never": "Never", + "generic.list.clearSelection": "Clear selection", + "generic.list.selected": "{count} row selected: | {count} rows selected:", "about.aboutN8n": "About n8n", "about.close": "Close", "about.license": "License", @@ -793,7 +795,6 @@ "executionsList.confirmMessage.message": "Are you sure that you want to delete the {count} selected execution(s)?", "executionsList.confirmMessage.annotationsNote": "By deleting these executions you will also remove the associated annotation data.", "executionsList.confirmMessage.annotatedExecutionMessage": "By deleting this you will also remove the associated annotation data. Are you sure that you want to delete the selected execution?", - "executionsList.clearSelection": "Clear selection", "executionsList.error": "Error", "executionsList.filters": "Filters", "executionsList.loadMore": "Load more", @@ -816,7 +817,6 @@ "executionsList.succeeded": "Succeeded", "executionsList.selectStatus": "Select Status", "executionsList.selectWorkflow": "Select Workflow", - "executionsList.selected": "{count} execution selected: | {count} executions selected:", "executionsList.selectAll": "Select {count} finished execution | Select all {count} finished executions", "executionsList.test": "Test execution", "executionsList.evaluation": "Evaluation execution", @@ -2877,6 +2877,10 @@ "dataStore.addRow.label": "Add Row", "dataStore.addRow.error": "Error adding row", "dataStore.updateRow.error": "Error updating row", + "dataStore.deleteRows.title": "Delete Rows", + "dataStore.deleteRows.confirmation": "Are you sure you want to delete {count} row? | Are you sure you want to delete {count} rows?", + "dataStore.deleteRows.success": "Rows deleted successfully", + "dataStore.deleteRows.error": "Error deleting rows", "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/src/components/common/SelectedItemsInfo.test.ts b/packages/frontend/editor-ui/src/components/common/SelectedItemsInfo.test.ts new file mode 100644 index 0000000000..012bddb866 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/common/SelectedItemsInfo.test.ts @@ -0,0 +1,61 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue'; + +const renderComponent = createComponentRenderer(SelectedItemsInfo); + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + baseText: (key: string) => key, + }), +})); + +describe('SelectedItemsInfo', () => { + it('should not render when selectedCount is 0', () => { + const { queryByTestId } = renderComponent({ + props: { + selectedCount: 0, + }, + }); + + expect(queryByTestId('selected-items-info')).not.toBeInTheDocument(); + }); + + it('should render when selectedCount is greater than 0', () => { + const { getByTestId } = renderComponent({ + props: { + selectedCount: 3, + }, + }); + + expect(getByTestId('selected-items-info')).toBeInTheDocument(); + expect(getByTestId('delete-selected-button')).toBeInTheDocument(); + expect(getByTestId('clear-selection-button')).toBeInTheDocument(); + }); + + it('should emit deleteSelected event when delete button is clicked', () => { + const { getByTestId, emitted } = renderComponent({ + props: { + selectedCount: 1, + }, + }); + + getByTestId('delete-selected-button').click(); + + expect(emitted().deleteSelected).toBeTruthy(); + expect(emitted().deleteSelected).toHaveLength(1); + }); + + it('should emit clearSelection event when clear button is clicked', () => { + const { getByTestId, emitted } = renderComponent({ + props: { + selectedCount: 5, + }, + }); + + getByTestId('clear-selection-button').click(); + + expect(emitted().clearSelection).toBeTruthy(); + expect(emitted().clearSelection).toHaveLength(1); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/common/SelectedItemsInfo.vue b/packages/frontend/editor-ui/src/components/common/SelectedItemsInfo.vue new file mode 100644 index 0000000000..ec32615fe1 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/common/SelectedItemsInfo.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts b/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts index e50570eeb4..c7ee44af01 100644 --- a/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts +++ b/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts @@ -26,6 +26,23 @@ vi.mock('vue-router', () => ({ RouterLink: vi.fn(), })); +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + displayTimer: (timer: number) => timer, + baseText: (key: string, options: { interpolate: { count: string } }) => { + if (key === 'generic.list.selected') { + return `${options.interpolate.count} executions selected`; + } else if (key === 'executionsList.retryOf') { + return 'Retry of'; + } else if (key === 'executionsList.successRetry') { + return 'Success retry'; + } + return key; + }, + }), +})); + let settingsStore: MockedStore; const generateUndefinedNullOrString = () => { @@ -148,7 +165,7 @@ describe('GlobalExecutionsList', () => { ).toBe(10), ); expect(getByTestId('select-all-executions-checkbox')).toBeInTheDocument(); - expect(getByTestId('selected-executions-info').textContent).toContain(10); + expect(getByTestId('selected-items-info').textContent).toContain(10); await userEvent.click(getByTestId('load-more-button')); await rerender({ @@ -170,7 +187,7 @@ describe('GlobalExecutionsList', () => { el.contains(el.querySelector(':checked')), ).length, ).toBe(20); - expect(getByTestId('selected-executions-info').textContent).toContain(20); + expect(getByTestId('selected-items-info').textContent).toContain(20); await userEvent.click(getAllByTestId('select-execution-checkbox')[2]); expect(getAllByTestId('select-execution-checkbox').length).toBe(20); @@ -179,7 +196,7 @@ describe('GlobalExecutionsList', () => { el.contains(el.querySelector(':checked')), ).length, ).toBe(19); - expect(getByTestId('selected-executions-info').textContent).toContain(19); + expect(getByTestId('selected-items-info').textContent).toContain(19); expect(getByTestId('select-visible-executions-checkbox')).toBeInTheDocument(); expect(queryByTestId('select-all-executions-checkbox')).not.toBeInTheDocument(); }, diff --git a/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue index 3ffcd91f0c..a3fe99fd96 100644 --- a/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue +++ b/packages/frontend/editor-ui/src/components/executions/global/GlobalExecutionsList.vue @@ -2,6 +2,7 @@ import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue'; import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue'; +import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue'; import { useI18n } from '@n8n/i18n'; import { useMessage } from '@/composables/useMessage'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; @@ -455,32 +456,11 @@ const goToUpgrade = () => { -
- - {{ - i18n.baseText('executionsList.selected', { - adjustToNumber: selectedCount, - interpolate: { count: `${selectedCount}` }, - }) - }} - - - -
+ @@ -507,25 +487,6 @@ const goToUpgrade = () => { margin-bottom: var(--spacing-s); } -.selectionOptions { - display: flex; - align-items: center; - position: absolute; - padding: var(--spacing-2xs); - z-index: 2; - left: 50%; - transform: translateX(-50%); - bottom: var(--spacing-3xl); - background: var(--execution-selector-background); - border-radius: var(--border-radius-base); - color: var(--execution-selector-text); - font-size: var(--font-size-2xs); - - button { - margin-left: var(--spacing-2xs); - } -} - .execTable { height: 100%; flex: 0 1 auto; 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 22607d0a07..05ba1cc611 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,5 +1,5 @@ diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.test.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.test.ts new file mode 100644 index 0000000000..38cb4fc8af --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.test.ts @@ -0,0 +1,40 @@ +import { deleteDataStoreRowsApi } from '@/features/dataStore/dataStore.api'; +import { makeRestApiRequest } from '@n8n/rest-api-client'; +import { expect } from 'vitest'; + +vi.mock('@n8n/rest-api-client', () => ({ + makeRestApiRequest: vi.fn(), +})); + +describe('dataStore.api', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('deleteDataStoreRowsApi', () => { + it('should make DELETE request with correct parameters', async () => { + const dataStoreId = 'test-datastore-id'; + const projectId = 'test-project-id'; + const rowIds = [1, 2, 3]; + + vi.mocked(makeRestApiRequest).mockResolvedValue(true); + + const result = await deleteDataStoreRowsApi( + { baseUrl: '/rest', pushRef: 'test-push-ref' }, + dataStoreId, + rowIds, + projectId, + ); + + expect(makeRestApiRequest).toHaveBeenCalledWith( + expect.anything(), + 'DELETE', + `/projects/${projectId}/data-stores/${dataStoreId}/rows`, + { + ids: '1,2,3', + }, + ); + expect(result).toBe(true); + }); + }); +}); 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 7ede5522fb..44de0423b6 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts @@ -178,3 +178,19 @@ export const updateDataStoreRowsApi = async ( }, ); }; + +export const deleteDataStoreRowsApi = async ( + context: IRestApiContext, + dataStoreId: string, + rowIds: number[], + projectId: string, +) => { + return await makeRestApiRequest( + context, + 'DELETE', + `/projects/${projectId}/data-stores/${dataStoreId}/rows`, + { + ids: rowIds.join(','), + }, + ); +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.test.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.test.ts index d5755f21ec..819850bf48 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.test.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.test.ts @@ -89,4 +89,22 @@ describe('dataStore.store', () => { ); expect(dataStoreStore.dataStores[0].columns.find((c) => c.id === columnId)).toBeUndefined(); }); + + it('can delete rows', async () => { + const datastoreId = faker.string.alphanumeric(10); + const projectId = 'p1'; + const rowIds = [1, 2, 3]; + + vi.spyOn(dataStoreApi, 'deleteDataStoreRowsApi').mockResolvedValue(true); + + const result = await dataStoreStore.deleteRows(datastoreId, projectId, rowIds); + + expect(result).toBe(true); + expect(dataStoreApi.deleteDataStoreRowsApi).toHaveBeenCalledWith( + rootStore.restApiContext, + datastoreId, + rowIds, + projectId, + ); + }); }); 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 eee4877323..7b783ee83c 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts @@ -13,6 +13,7 @@ import { getDataStoreRowsApi, insertDataStoreRowApi, updateDataStoreRowsApi, + deleteDataStoreRowsApi, } from '@/features/dataStore/dataStore.api'; import type { DataStore, @@ -208,6 +209,10 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { ); }; + const deleteRows = async (dataStoreId: string, projectId: string, rowIds: number[]) => { + return await deleteDataStoreRowsApi(rootStore.restApiContext, dataStoreId, rowIds, projectId); + }; + return { dataStores, totalCount, @@ -223,5 +228,6 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { fetchDataStoreContent, insertEmptyRow, updateRow, + deleteRows, }; });