feat(editor): Implement delete datastore row UI (no-changelog) (#18729)

This commit is contained in:
Svetoslav Dekov
2025-08-25 14:59:16 +02:00
committed by GitHub
parent 2dc34b2f17
commit 67352ce2f8
10 changed files with 329 additions and 52 deletions

View File

@@ -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 <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",

View File

@@ -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);
});
});

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { N8nButton } from '@n8n/design-system';
interface Props {
selectedCount: number;
}
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<{
deleteSelected: [];
clearSelection: [];
}>();
const i18n = useI18n();
const getSelectedText = () => {
return i18n.baseText('generic.list.selected', {
adjustToNumber: props.selectedCount,
interpolate: { count: `${props.selectedCount}` },
});
};
const getClearSelectionText = () => {
return i18n.baseText('generic.list.clearSelection');
};
const handleDeleteSelected = () => {
emit('deleteSelected');
};
const handleClearSelection = () => {
emit('clearSelection');
};
</script>
<template>
<div
v-if="selectedCount > 0"
:class="$style.selectionOptions"
:data-test-id="`selected-items-info`"
>
<span>
{{ getSelectedText() }}
</span>
<N8nButton
:label="i18n.baseText('generic.delete')"
type="tertiary"
data-test-id="delete-selected-button"
@click="handleDeleteSelected"
/>
<N8nButton
:label="getClearSelectionText()"
type="tertiary"
data-test-id="clear-selection-button"
@click="handleClearSelection"
/>
</div>
</template>
<style module lang="scss">
.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);
}
}
</style>

View File

@@ -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<typeof useSettingsStore>;
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();
},

View File

@@ -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 = () => {
</N8nTableBase>
</div>
</div>
<div
v-if="selectedCount > 0"
:class="$style.selectionOptions"
data-test-id="selected-executions-info"
>
<span>
{{
i18n.baseText('executionsList.selected', {
adjustToNumber: selectedCount,
interpolate: { count: `${selectedCount}` },
})
}}
</span>
<N8nButton
:label="i18n.baseText('generic.delete')"
type="tertiary"
data-test-id="delete-selected-button"
@click="handleDeleteSelected"
/>
<N8nButton
:label="i18n.baseText('executionsList.clearSelection')"
type="tertiary"
data-test-id="clear-selection-button"
@click="handleClearSelection"
/>
</div>
<SelectedItemsInfo
:selected-count="selectedCount"
@delete-selected="handleDeleteSelected"
@clear-selection="handleClearSelection"
/>
</div>
</template>
@@ -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;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import orderBy from 'lodash/orderBy';
import type {
DataStore,
@@ -41,6 +41,7 @@ import {
} 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';
@@ -121,6 +122,9 @@ const totalItems = ref(0);
// Data store content
const rows = ref<DataStoreRow[]>([]);
const selectedRowIds = ref<Set<number>>(new Set());
const selectedCount = computed(() => selectedRowIds.value.size);
const onGridReady = (params: GridReadyEvent) => {
gridApi.value = params.api;
};
@@ -436,6 +440,8 @@ const fetchDataStoreContent = async () => {
rows.value = fetchedRows.data;
totalItems.value = fetchedRows.count;
rowData.value = rows.value;
handleClearSelection();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
} finally {
@@ -476,6 +482,68 @@ const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) =>
isTextEditorOpen.value = false;
}
};
const onSelectionChanged = () => {
if (!gridApi.value) return;
const selectedNodes = gridApi.value.getSelectedNodes();
const newSelectedIds = new Set<number>();
selectedNodes.forEach((node) => {
if (typeof node.data?.id === 'number') {
newSelectedIds.add(node.data.id);
}
});
selectedRowIds.value = newSelectedIds;
};
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 {
emit('toggleSave', true);
const idsToDelete = Array.from(selectedRowIds.value);
await dataStoreStore.deleteRows(props.dataStore.id, props.dataStore.projectId, idsToDelete);
rows.value = rows.value.filter((row) => !selectedRowIds.value.has(row.id as number));
rowData.value = rows.value;
await fetchDataStoreContent();
toast.showMessage({
title: i18n.baseText('dataStore.deleteRows.success'),
type: 'success',
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.deleteRows.error'));
} finally {
emit('toggleSave', false);
}
};
const handleClearSelection = () => {
selectedRowIds.value = new Set();
if (gridApi.value) {
gridApi.value.deselectAll();
}
};
</script>
<template>
@@ -504,7 +572,7 @@ const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) =>
@cell-editing-started="onCellEditingStarted"
@cell-editing-stopped="onCellEditingStopped"
@column-header-clicked="resetLastFocusedCell"
@selection-changed="resetLastFocusedCell"
@selection-changed="onSelectionChanged"
/>
<AddColumnPopover
:data-store="props.dataStore"
@@ -534,6 +602,11 @@ const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) =>
@size-change="setPageSize"
/>
</div>
<SelectedItemsInfo
:selected-count="selectedCount"
@delete-selected="handleDeleteSelected"
@clear-selection="handleClearSelection"
/>
</div>
</template>

View File

@@ -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);
});
});
});

View File

@@ -178,3 +178,19 @@ export const updateDataStoreRowsApi = async (
},
);
};
export const deleteDataStoreRowsApi = async (
context: IRestApiContext,
dataStoreId: string,
rowIds: number[],
projectId: string,
) => {
return await makeRestApiRequest<boolean>(
context,
'DELETE',
`/projects/${projectId}/data-stores/${dataStoreId}/rows`,
{
ids: rowIds.join(','),
},
);
};

View File

@@ -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,
);
});
});

View File

@@ -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,
};
});