mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Implement delete datastore row UI (no-changelog) (#18729)
This commit is contained in:
@@ -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>",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(','),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user