feat(editor): Data tables FE tweaks (no-changelog) (#18877)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Svetoslav Dekov
2025-08-28 17:34:30 +03:00
committed by GitHub
parent a15391acc9
commit 93e08d8735
15 changed files with 124 additions and 58 deletions

View File

@@ -4,6 +4,7 @@ import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow';
import { DataStoreRowsRepository } from './data-store-rows.repository';
import { DataTable } from './data-table.entity';
import { DataTableColumn } from './data-table-column.entity';
import { DataStoreColumnNameConflictError } from './errors/data-store-column-name-conflict.error';
import { DataStoreValidationError } from './errors/data-store-validation.error';
@@ -40,7 +41,11 @@ export class DataStoreColumnRepository extends Repository<DataTableColumn> {
});
if (existingColumnMatch) {
throw new DataStoreColumnNameConflictError(schema.name, dataTableId);
const dataTable = await em.findOneBy(DataTable, { id: dataTableId });
if (!dataTable) {
throw new UnexpectedError('Data store not found');
}
throw new DataStoreColumnNameConflictError(schema.name, dataTable.name);
}
if (schema.index === undefined) {

View File

@@ -1,9 +1,9 @@
import { UserError } from 'n8n-workflow';
export class DataStoreColumnNameConflictError extends UserError {
constructor(columnName: string, dataStoreId: string) {
constructor(columnName: string, dataStoreName: string) {
super(
`Data store column with name '${columnName}' already exists in data store '${dataStoreId}'`,
`Data store column with name '${columnName}' already exists in data store '${dataStoreName}'`,
{
level: 'warning',
},

View File

@@ -47,7 +47,6 @@ 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 },

View File

@@ -9,6 +9,8 @@ import { createTestingPinia } from '@pinia/testing';
import { createRouter, createWebHistory } from 'vue-router';
import type { DataStoreResource } from '@/features/dataStore/types';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import type { Mock } from 'vitest';
import { type Project } from '@/types/projects.types';
vi.mock('@/composables/useProjectPages', () => ({
useProjectPages: vi.fn().mockReturnValue({
@@ -102,7 +104,6 @@ const TEST_DATA_STORE: DataStoreResource = {
id: '1',
name: 'Test Data Store',
sizeBytes: 1024,
recordCount: 100,
columns: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -126,7 +127,7 @@ describe('DataStoreView', () => {
dataStoreStore.totalCount = 1;
dataStoreStore.fetchDataStores = vi.fn().mockResolvedValue(undefined);
projectsStore.getCurrentProjectId = vi.fn(() => 'test-project');
projectsStore.currentProjectId = '';
sourceControlStore.isProjectShared = vi.fn(() => false);
});
@@ -135,10 +136,23 @@ describe('DataStoreView', () => {
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25);
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('', 1, 25);
expect(getByTestId('resources-list-wrapper')).toBeInTheDocument();
});
it('should filter by project if not on overview sub page', async () => {
(useProjectPages as Mock).mockReturnValue({
isOverviewSubPage: false,
});
projectsStore.currentProjectId = 'test-project';
projectsStore.currentProject = {
id: 'test-project',
} as Project;
renderComponent({ pinia });
await waitAllPromises();
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25);
});
it('should set document title on mount', async () => {
renderComponent({ pinia });
await waitAllPromises();
@@ -224,7 +238,7 @@ describe('DataStoreView', () => {
await waitAllPromises();
// Initial call should use default page size of 25
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25);
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('', 1, 25);
});
});
});

View File

@@ -52,19 +52,20 @@ const dataStoreResources = computed<DataStoreResource[]>(() =>
}),
);
const projectId = computed(() => {
return Array.isArray(route.params.projectId) ? route.params.projectId[0] : route.params.projectId;
});
const totalCount = computed(() => dataStoreStore.totalCount);
const currentProject = computed(() => projectsStore.currentProject);
const currentProject = computed(() => {
if (projectPages.isOverviewSubPage) {
return projectsStore.personalProject;
}
return projectsStore.currentProject;
});
const projectName = computed(() => {
if (currentProject.value?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject.value?.name;
return currentProject.value?.name ?? '';
});
const emptyCalloutDescription = computed(() => {
@@ -72,9 +73,6 @@ const emptyCalloutDescription = computed(() => {
});
const emptyCalloutButtonText = computed(() => {
if (projectPages.isOverviewSubPage || !projectName.value) {
return '';
}
return i18n.baseText('dataStore.empty.button.label', {
interpolate: { projectName: projectName.value },
});
@@ -84,8 +82,9 @@ const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly
const initialize = async () => {
loading.value = true;
const projectIdFilter = projectPages.isOverviewSubPage ? '' : projectsStore.currentProjectId;
try {
await dataStoreStore.fetchDataStores(projectId.value, currentPage.value, pageSize.value);
await dataStoreStore.fetchDataStores(projectIdFilter ?? '', currentPage.value, pageSize.value);
} catch (error) {
toast.showError(error, 'Error loading data stores');
} finally {
@@ -108,7 +107,7 @@ const onPaginationUpdate = async (payload: SortingAndPaginationUpdates) => {
const onAddModalClick = () => {
void router.push({
name: PROJECT_DATA_STORES,
params: { projectId: projectId.value, new: 'new' },
params: { projectId: currentProject.value?.id, new: 'new' },
});
};

View File

@@ -53,7 +53,6 @@ const mockDataStore: DataStore = {
id: '1',
name: 'Test DataStore',
sizeBytes: 1024,
recordCount: 100,
columns: [],
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',

View File

@@ -52,7 +52,6 @@ const mockDataStore: DataStore = {
id: '1',
name: 'Test DataStore',
sizeBytes: 1024,
recordCount: 100,
columns: [],
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',

View File

@@ -28,7 +28,6 @@ const DEFAULT_DATA_STORE: DataStoreResource = {
id: '1',
name: 'Test Data Store',
sizeBytes: 1024,
recordCount: 100,
columns: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -84,7 +83,6 @@ describe('DataStoreCard', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-card-icon')).toBeInTheDocument();
expect(getByTestId('data-store-card-name')).toHaveTextContent(DEFAULT_DATA_STORE.name);
expect(getByTestId('data-store-card-record-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-column-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-last-updated')).toHaveTextContent('Last updated');
expect(getByTestId('data-store-card-created')).toHaveTextContent('Created');
@@ -114,13 +112,6 @@ describe('DataStoreCard', () => {
);
});
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');

View File

@@ -54,18 +54,6 @@ const dataStoreRoute = computed(() => {
</template>
<template #footer>
<div :class="$style['card-footer']">
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--record-count']]"
data-test-id="data-store-card-record-count"
>
{{
i18n.baseText('dataStore.card.row.count', {
interpolate: { count: props.dataStore.recordCount ?? 0 },
})
}}
</N8nText>
<N8nText
size="small"
color="text-light"
@@ -155,7 +143,6 @@ const dataStoreRoute = computed(() => {
flex-wrap: wrap;
}
.info-cell--created,
.info-cell--record-count,
.info-cell--column-count {
display: none;
}

View File

@@ -44,6 +44,7 @@ vi.mock('ag-grid-community', () => ({
ValidationModule: {},
UndoRedoEditModule: {},
CellStyleModule: {},
ScrollApiModule: {},
}));
// Mock the n8n theme
@@ -109,7 +110,6 @@ const mockDataStore: DataStore = {
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
sizeBytes: 0,
recordCount: 0,
};
describe('DataStoreTable', () => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { computed, onMounted, ref, nextTick, useTemplateRef } from 'vue';
import orderBy from 'lodash/orderBy';
import type {
DataStore,
@@ -23,6 +23,7 @@ import type {
ValueSetterParams,
CellEditingStartedEvent,
CellEditingStoppedEvent,
CellKeyDownEvent,
} from 'ag-grid-community';
import {
ModuleRegistry,
@@ -39,6 +40,7 @@ import {
ValidationModule,
UndoRedoEditModule,
CellStyleModule,
ScrollApiModule,
} from 'ag-grid-community';
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue';
@@ -63,6 +65,8 @@ import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import { onClickOutside } from '@vueuse/core';
import { useClipboard } from '@/composables/useClipboard';
import { reorderItem } from '@/features/dataStore/utils';
// Register only the modules we actually use
ModuleRegistry.registerModules([
@@ -79,6 +83,7 @@ ModuleRegistry.registerModules([
ClientSideRowModelApiModule,
UndoRedoEditModule,
CellStyleModule,
ScrollApiModule,
]);
type Props = {
@@ -98,6 +103,8 @@ const { mapToAGCellType } = useDataStoreTypes();
const dataStoreStore = useDataStoreStore();
useClipboard({ onPaste: onClipboardPaste });
// AG Grid State
const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]);
@@ -146,6 +153,20 @@ const refreshGridData = () => {
}
};
const focusFirstEditableCell = (rowId: number) => {
if (!gridApi.value) return;
const rowNode = gridApi.value.getRowNode(String(rowId));
if (rowNode?.rowIndex === null) return;
const firstEditableCol = colDefs.value[1];
if (!firstEditableCol?.colId) return;
gridApi.value.ensureIndexVisible(rowNode!.rowIndex);
gridApi.value.setFocusedCell(rowNode!.rowIndex, firstEditableCol.colId);
gridApi.value.startEditingCell({ rowIndex: rowNode!.rowIndex, colKey: firstEditableCol.colId });
};
const setCurrentPage = async (page: number) => {
currentPage.value = page;
await fetchDataStoreContent();
@@ -287,6 +308,8 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
// Provide initial value for the editor, otherwise agLargeTextCellEditor breaks
columnDef.cellEditorParams = (params: CellEditRequestEvent<DataStoreRow>) => ({
value: params.value ?? '',
// Rely on the backend to limit the length of the value
maxLength: 999999999,
});
columnDef.valueSetter = (params: ValueSetterParams<DataStoreRow>) => {
let originalValue = params.data[col.name];
@@ -341,8 +364,17 @@ const onColumnMoved = async (moveEvent: ColumnMovedEvent) => {
props.dataStore.id,
props.dataStore.projectId,
moveEvent.column.getColId(),
moveEvent.toIndex - 1,
moveEvent.toIndex - 2, // ag grid index start from 1 and also we need to account for the id column
);
// Compute positions within the movable middle section (exclude selection + ID + Add Column)
const fromIndex = oldIndex - 1; // exclude ID column
const toIndex = moveEvent.toIndex - 2; // exclude selection + ID columns used by AG Grid indices
const middleWithIndex = colDefs.value.slice(1, -1).map((col, index) => ({ ...col, index }));
const reorderedMiddle = reorderItem(middleWithIndex, fromIndex, toIndex)
.sort((a, b) => a.index - b.index)
.map(({ index, ...col }) => col);
colDefs.value = [colDefs.value[0], ...reorderedMiddle, colDefs.value[colDefs.value.length - 1]];
refreshGridData();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.moveColumn.error'));
gridApi.value?.moveColumnByIndex(moveEvent.toIndex, oldIndex);
@@ -366,6 +398,8 @@ const onAddRowClick = async () => {
rowData.value.push(newRow);
totalItems.value += 1;
refreshGridData();
await nextTick();
focusFirstEditableCell(newRowId);
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addRow.error'));
} finally {
@@ -518,8 +552,25 @@ const fetchDataStoreContent = async () => {
onClickOutside(gridContainer, () => {
resetLastFocusedCell();
gridApi.value?.clearFocusedCell();
});
function onClipboardPaste(data: string) {
if (!gridApi.value) return;
const focusedCell = gridApi.value.getFocusedCell();
const isEditing = gridApi.value.getEditingCells().length > 0;
if (!focusedCell || isEditing) return;
const row = gridApi.value.getDisplayedRowAtIndex(focusedCell.rowIndex);
if (!row) return;
const colDef = focusedCell.column.getColDef();
if (colDef.cellDataType === 'text') {
row.setDataValue(focusedCell.column.getColId(), data);
} else if (!Number.isNaN(Number(data))) {
row.setDataValue(focusedCell.column.getColId(), Number(data));
}
}
const resetLastFocusedCell = () => {
lastFocusedCell.value = null;
};
@@ -562,6 +613,17 @@ const onSelectionChanged = () => {
selectedRowIds.value = newSelectedIds;
};
const onCellKeyDown = async (params: CellKeyDownEvent<DataStoreRow>) => {
const key = (params.event as KeyboardEvent).key;
if (key !== 'Delete' && key !== 'Backspace') return;
const isEditing = params.api.getEditingCells().length > 0;
if (isEditing || selectedRowIds.value.size === 0) return;
params.event?.preventDefault();
await handleDeleteSelected();
};
const handleDeleteSelected = async () => {
if (selectedRowIds.value.size === 0) return;
@@ -639,6 +701,7 @@ defineExpose({
@cell-editing-stopped="onCellEditingStopped"
@column-header-clicked="resetLastFocusedCell"
@selection-changed="onSelectionChanged"
@cell-key-down="onCellKeyDown"
/>
</div>
<div :class="$style.footer">

View File

@@ -29,7 +29,6 @@ describe('dataStore.store', () => {
id: datastoreId,
name: 'Test',
sizeBytes: 0,
recordCount: 0,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
projectId,

View File

@@ -21,6 +21,7 @@ import type {
DataStoreRow,
} from '@/features/dataStore/datastore.types';
import { useProjectsStore } from '@/stores/projects.store';
import { reorderItem } from '@/features/dataStore/utils';
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const rootStore = useRootStore();
@@ -154,16 +155,11 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
if (moved) {
const dsIndex = dataStores.value.findIndex((store) => store.id === datastoreId);
const fromIndex = dataStores.value[dsIndex].columns.findIndex((col) => col.id === columnId);
dataStores.value[dsIndex].columns = dataStores.value[dsIndex].columns.map((col) => {
if (col.id === columnId) return { ...col, index: targetIndex };
if (fromIndex < targetIndex && col.index > fromIndex && col.index <= targetIndex) {
return { ...col, index: col.index - 1 };
}
if (fromIndex > targetIndex && col.index >= targetIndex && col.index < fromIndex) {
return { ...col, index: col.index + 1 };
}
return col;
});
dataStores.value[dsIndex].columns = reorderItem(
dataStores.value[dsIndex].columns,
fromIndex,
targetIndex,
);
}
return moved;
};

View File

@@ -4,7 +4,6 @@ export type DataStore = {
id: string;
name: string;
sizeBytes: number;
recordCount: number;
columns: DataStoreColumn[];
createdAt: string;
updatedAt: string;

View File

@@ -0,0 +1,16 @@
export const reorderItem = <T extends { index: number }>(
items: T[],
oldIndex: number,
newIndex: number,
) => {
return items.map((item) => {
if (item.index === oldIndex) return { ...item, index: newIndex };
if (oldIndex < newIndex && item.index > oldIndex && item.index <= newIndex) {
return { ...item, index: item.index - 1 };
}
if (oldIndex > newIndex && item.index >= newIndex && item.index < oldIndex) {
return { ...item, index: item.index + 1 };
}
return item;
});
};