mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Data tables FE tweaks (no-changelog) (#18877)
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ export type DataStore = {
|
||||
id: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
recordCount: number;
|
||||
columns: DataStoreColumn[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
16
packages/frontend/editor-ui/src/features/dataStore/utils.ts
Normal file
16
packages/frontend/editor-ui/src/features/dataStore/utils.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user