feat(editor): Implement row management for data stores (no-changelog) (#18483)

This commit is contained in:
Milorad FIlipović
2025-08-19 09:54:04 +02:00
committed by GitHub
parent 82876b711a
commit fd94d71b6d
9 changed files with 336 additions and 33 deletions

View File

@@ -80,6 +80,7 @@
"generic.tryNow": "Try now",
"generic.startNow": "Start now",
"generic.dismiss": "Dismiss",
"generic.saving": "Saving",
"generic.unsavedWork.confirmMessage.headline": "Save changes before leaving?",
"generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.",
"generic.unsavedWork.confirmMessage.confirmButtonText": "Save",
@@ -2870,6 +2871,10 @@
"dataStore.addColumn.error": "Error adding column",
"dataStore.addColumn.invalidName.error": "Invalid column name",
"dataStore.addColumn.invalidName.description": "Only alphanumeric characters and non-leading dashes are allowed for column names",
"dataStore.fetchContent.error": "Error fetching data store content",
"dataStore.addRow.label": "Add Row",
"dataStore.addRow.error": "Error adding row",
"dataStore.updateRow.error": "Error updating row",
"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

@@ -5,10 +5,11 @@ import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { DATA_STORE_VIEW } from '@/features/dataStore/constants';
import { DATA_STORE_VIEW, MIN_LOADING_TIME } from '@/features/dataStore/constants';
import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import DataStoreTable from './components/dataGrid/DataStoreTable.vue';
import { useDebounce } from '@/composables/useDebounce';
type Props = {
id: string;
@@ -25,7 +26,9 @@ const documentTitle = useDocumentTitle();
const dataStoreStore = useDataStoreStore();
const loading = ref(false);
const saving = ref(false);
const dataStore = ref<DataStore | null>(null);
const { debounce } = useDebounce();
const showErrorAndGoBackToList = async (error: unknown) => {
if (!(error instanceof Error)) {
@@ -51,6 +54,30 @@ const initialize = async () => {
}
};
// Debounce creating new timer slightly if saving is initiated fast in succession
const debouncedSetSaving = debounce(
(value: boolean) => {
saving.value = value;
},
{ debounceTime: 50, trailing: true },
);
// Debounce hiding the saving indicator so users can see saving state
const debouncedHideSaving = debounce(
() => {
saving.value = false;
},
{ debounceTime: MIN_LOADING_TIME, trailing: true },
);
const onToggleSave = (value: boolean) => {
if (value) {
debouncedSetSaving(true);
} else {
debouncedHideSaving();
}
};
onMounted(async () => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
await initialize();
@@ -72,9 +99,13 @@ onMounted(async () => {
<div v-else-if="dataStore">
<div :class="$style.header">
<DataStoreBreadcrumbs :data-store="dataStore" />
<div v-if="saving" :class="$style.saving">
<n8n-spinner />
<n8n-text>{{ i18n.baseText('generic.saving') }}...</n8n-text>
</div>
</div>
<div :class="$style.content">
<DataStoreTable :data-store="dataStore" />
<DataStoreTable :data-store="dataStore" @toggle-save="onToggleSave" />
</div>
</div>
</div>
@@ -102,8 +133,15 @@ onMounted(async () => {
.header {
display: flex;
gap: var(--spacing-l);
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xl);
}
.saving {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
margin-top: var(--spacing-5xs);
}
</style>

View File

@@ -19,7 +19,8 @@ vi.mock('ag-grid-vue3', () => ({
mounted(this: MockComponentInstance) {
this.$emit('gridReady', {
api: {
// Mock API methods
refreshHeader: vi.fn(),
applyTransaction: vi.fn(),
},
});
},
@@ -37,6 +38,12 @@ vi.mock('ag-grid-community', () => ({
ColumnAutoSizeModule: {},
CheckboxEditorModule: {},
NumberEditorModule: {},
RowSelectionModule: {},
RenderApiModule: {},
DateEditorModule: {},
ClientSideRowModelApiModule: {},
ValidationModule: {},
UndoRedoEditModule: {},
}));
// Mock the n8n theme

View File

@@ -15,24 +15,43 @@ import {
ColumnAutoSizeModule,
CheckboxEditorModule,
NumberEditorModule,
RowSelectionModule,
RenderApiModule,
DateEditorModule,
ClientSideRowModelApiModule,
ValidationModule,
UndoRedoEditModule,
} from 'ag-grid-community';
import type {
GridApi,
GridReadyEvent,
ColDef,
RowSelectionOptions,
CellValueChangedEvent,
ValueGetterParams,
} from 'ag-grid-community';
import type { GridApi, GridReadyEvent, ColDef } from 'ag-grid-community';
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { DEFAULT_ID_COLUMN_NAME } from '@/features/dataStore/constants';
import { DEFAULT_ID_COLUMN_NAME, NO_TABLE_YET_MESSAGE } from '@/features/dataStore/constants';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
// Register only the modules we actually use
ModuleRegistry.registerModules([
ValidationModule, // This module allows us to see AG Grid errors in browser console
ClientSideRowModelModule,
TextEditorModule,
LargeTextEditorModule,
ColumnAutoSizeModule,
CheckboxEditorModule,
NumberEditorModule,
RowSelectionModule,
RenderApiModule,
DateEditorModule,
ClientSideRowModelApiModule,
UndoRedoEditModule,
]);
type Props = {
@@ -41,6 +60,10 @@ type Props = {
const props = defineProps<Props>();
const emit = defineEmits<{
toggleSave: [value: boolean];
}>();
const i18n = useI18n();
const toast = useToast();
const dataStoreTypes = useDataStoreTypes();
@@ -51,6 +74,13 @@ const dataStoreStore = useDataStoreStore();
const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]);
const rowData = ref<DataStoreRow[]>([]);
const rowSelection: RowSelectionOptions | 'single' | 'multiple' = {
mode: 'singleRow',
enableClickSelection: true,
checkboxes: false,
};
const contentLoading = ref(false);
// Shared config for all columns
const defaultColumnDef = {
@@ -60,22 +90,26 @@ const defaultColumnDef = {
};
// Pagination
const pageSizeOptions = [10, 20, 50];
const currentPage = ref(1);
const pageSize = ref(20);
const pageSizeOptions = ref([10, 20, 50]);
const totalItems = ref(0);
// Data store content
const rows = ref<DataStoreRow[]>([]);
const onGridReady = (params: GridReadyEvent) => {
gridApi.value = params.api;
};
const setCurrentPage = (page: number) => {
const setCurrentPage = async (page: number) => {
currentPage.value = page;
await fetchDataStoreContent();
};
const setPageSize = (size: number) => {
const setPageSize = async (size: number) => {
pageSize.value = size;
currentPage.value = 1; // Reset to first page on page size change
await fetchDataStoreContent();
};
const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) => {
@@ -101,15 +135,52 @@ const createColumnDef = (col: DataStoreColumn) => {
headerName: col.name,
editable: col.name !== DEFAULT_ID_COLUMN_NAME,
cellDataType: dataStoreTypes.mapToAGCellType(col.type),
valueGetter: (params: ValueGetterParams<DataStoreRow>) => {
// If the value is null, return null to show empty cell
if (params.data?.[col.name] === null || params.data?.[col.name] === undefined) {
return null;
}
// Parse dates
if (col.type === 'date') {
const value = params.data?.[col.name];
if (typeof value === 'string') {
return new Date(value);
}
}
return params.data?.[col.name];
},
};
// Enable large text editor for text columns
if (col.type === 'string') {
columnDef.cellEditor = 'agLargeTextCellEditor';
columnDef.cellEditorPopup = true;
}
// Setup date editor
if (col.type === 'date') {
columnDef.cellEditor = 'agDateCellEditor';
}
return columnDef;
};
const onAddRowClick = async () => {
try {
// Go to last page if we are not there already
if (currentPage.value * pageSize.value < totalItems.value) {
await setCurrentPage(Math.ceil(totalItems.value / pageSize.value));
}
const inserted = await dataStoreStore.insertEmptyRow(props.dataStore);
if (!inserted) {
throw new Error(i18n.baseText('generic.unknownError'));
}
emit('toggleSave', true);
await fetchDataStoreContent();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addRow.error'));
} finally {
emit('toggleSave', false);
}
};
const initColumnDefinitions = () => {
colDefs.value = [
// Always add the ID column, it's not returned by the back-end but all data stores have it
@@ -125,12 +196,54 @@ const initColumnDefinitions = () => {
];
};
const initialize = () => {
initColumnDefinitions();
const onCellValueChanged = async (params: CellValueChangedEvent) => {
const { data, api } = params;
try {
emit('toggleSave', true);
await dataStoreStore.upsertRow(props.dataStore.id, props.dataStore.projectId, data);
} catch (error) {
// Revert cell to original value if the update fails
api.undoCellEditing();
toast.showError(error, i18n.baseText('dataStore.updateRow.error'));
} finally {
emit('toggleSave', false);
}
};
onMounted(() => {
initialize();
const fetchDataStoreContent = async () => {
try {
contentLoading.value = true;
const fetchedRows = await dataStoreStore.fetchDataStoreContent(
props.dataStore.id,
props.dataStore.projectId,
currentPage.value,
pageSize.value,
);
rows.value = fetchedRows.data;
totalItems.value = fetchedRows.count;
rowData.value = rows.value;
} catch (error) {
// TODO: We currently don't create user tables until user columns or rows are added
// so we need to ignore NO_TABLE_YET_MESSAGE error here
if ('message' in error && !error.message.includes(NO_TABLE_YET_MESSAGE)) {
toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
}
} finally {
contentLoading.value = false;
if (gridApi.value) {
gridApi.value.refreshHeader();
}
}
};
const initialize = async () => {
initColumnDefinitions();
await fetchDataStoreContent();
};
onMounted(async () => {
await initialize();
});
</script>
@@ -145,7 +258,14 @@ onMounted(() => {
:dom-layout="'autoHeight'"
:animate-rows="false"
:theme="n8nTheme"
:loading="contentLoading"
:row-selection="rowSelection"
:get-row-id="(params) => String(params.data.id)"
:single-click-edit="true"
:stop-editing-when-cells-lose-focus="true"
:undo-redo-cell-editing="true"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
/>
<AddColumnPopover
:data-store="props.dataStore"
@@ -154,20 +274,23 @@ onMounted(() => {
/>
</div>
<div :class="$style.footer">
<n8n-icon-button
icon="plus"
class="mb-xl"
type="secondary"
data-test-id="data-store-add-row-button"
/>
<n8n-tooltip :content="i18n.baseText('dataStore.addRow.label')">
<n8n-icon-button
data-test-id="data-store-add-row-button"
icon="plus"
class="mb-xl"
type="secondary"
@click="onAddRowClick"
/>
</n8n-tooltip>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
data-test-id="data-store-content-pagination"
background
:total="totalItems"
:page-sizes="pageSizeOptions"
layout="total, prev, pager, next, sizes"
data-test-id="data-store-content-pagination"
@update:current-page="setCurrentPage"
@size-change="setPageSize"
/>
@@ -199,7 +322,6 @@ onMounted(() => {
--ag-font-family: var(--font-family);
--ag-font-size: var(--font-size-xs);
--ag-row-height: calc(var(--ag-grid-size) * 0.8 + 32px);
--ag-header-background-color: var(--color-background-base);
--ag-header-font-size: var(--font-size-xs);
--ag-header-font-weight: var(--font-weight-bold);

View File

@@ -1,5 +1,9 @@
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { AGGridCellType, DataStoreColumnType } from '@/features/dataStore/datastore.types';
import type {
AGGridCellType,
DataStoreColumnType,
DataStoreValue,
} from '@/features/dataStore/datastore.types';
/* eslint-disable id-denylist */
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
@@ -27,8 +31,24 @@ export const useDataStoreTypes = () => {
return colType;
};
const getDefaultValueForType = (colType: DataStoreColumnType): DataStoreValue => {
switch (colType) {
case 'string':
return '';
case 'number':
return 0;
case 'boolean':
return false;
case 'date':
return null;
default:
return null;
}
};
return {
getIconForType,
mapToAGCellType,
getDefaultValueForType,
};
};

View File

@@ -19,3 +19,7 @@ export const DEFAULT_ID_COLUMN_NAME = 'id';
export const MAX_COLUMN_NAME_LENGTH = 128;
export const COLUMN_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
export const NO_TABLE_YET_MESSAGE = 'SQLITE_ERROR: no such table:';
export const MIN_LOADING_TIME = 500; // ms

View File

@@ -5,6 +5,7 @@ import type {
DataStoreColumnCreatePayload,
DataStore,
DataStoreColumn,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
export const fetchDataStoresApi = async (
@@ -17,7 +18,7 @@ export const fetchDataStoresApi = async (
filter?: {
id?: string | string[];
name?: string | string[];
projectId?: string | string[];
projectId: string | string[];
},
) => {
const apiEndpoint = projectId ? `/projects/${projectId}/data-stores` : '/data-stores-global';
@@ -26,8 +27,8 @@ export const fetchDataStoresApi = async (
'GET',
apiEndpoint,
{
...options,
...(filter ?? {}),
options: options ?? undefined,
filter: filter ?? undefined,
},
);
};
@@ -35,7 +36,7 @@ export const fetchDataStoresApi = async (
export const createDataStoreApi = async (
context: IRestApiContext,
name: string,
projectId?: string,
projectId: string,
columns?: DataStoreColumnCreatePayload[],
) => {
return await makeRestApiRequest<DataStore>(
@@ -52,7 +53,7 @@ export const createDataStoreApi = async (
export const deleteDataStoreApi = async (
context: IRestApiContext,
dataStoreId: string,
projectId?: string,
projectId: string,
) => {
return await makeRestApiRequest<boolean>(
context,
@@ -69,7 +70,7 @@ export const updateDataStoreApi = async (
context: IRestApiContext,
dataStoreId: string,
name: string,
projectId?: string,
projectId: string,
) => {
return await makeRestApiRequest<DataStore>(
context,
@@ -96,3 +97,54 @@ export const addDataStoreColumnApi = async (
},
);
};
export const getDataStoreRowsApi = async (
context: IRestApiContext,
dataStoreId: string,
projectId: string,
options?: {
skip?: number;
take?: number;
},
) => {
return await makeRestApiRequest<{
count: number;
data: DataStoreRow[];
}>(context, 'GET', `/projects/${projectId}/data-stores/${dataStoreId}/rows`, {
...(options ?? {}),
});
};
export const insertDataStoreRowApi = async (
context: IRestApiContext,
dataStoreId: string,
row: DataStoreRow,
projectId: string,
) => {
return await makeRestApiRequest<boolean>(
context,
'POST',
`/projects/${projectId}/data-stores/${dataStoreId}/insert`,
{
data: [row],
},
);
};
export const upsertDataStoreRowsApi = async (
context: IRestApiContext,
dataStoreId: string,
rows: DataStoreRow[],
projectId: string,
matchFields: string[] = ['id'],
) => {
return await makeRestApiRequest<boolean>(
context,
'POST',
`/projects/${projectId}/data-stores/${dataStoreId}/upsert`,
{
rows,
matchFields,
},
);
};

View File

@@ -8,14 +8,24 @@ import {
deleteDataStoreApi,
updateDataStoreApi,
addDataStoreColumnApi,
getDataStoreRowsApi,
insertDataStoreRowApi,
upsertDataStoreRowsApi,
} from '@/features/dataStore/dataStore.api';
import type { DataStore, DataStoreColumnCreatePayload } from '@/features/dataStore/datastore.types';
import type {
DataStore,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
import { useProjectsStore } from '@/stores/projects.store';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const rootStore = useRootStore();
const projectStore = useProjectsStore();
const dataStoreTypes = useDataStoreTypes();
const dataStores = ref<DataStore[]>([]);
const totalCount = ref(0);
@@ -28,7 +38,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
totalCount.value = response.count;
};
const createDataStore = async (name: string, projectId?: string) => {
const createDataStore = async (name: string, projectId: string) => {
const newStore = await createDataStoreApi(rootStore.restApiContext, name, projectId);
if (!newStore.project && projectId) {
const project = await projectStore.fetchProject(projectId);
@@ -41,7 +51,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
return newStore;
};
const deleteDataStore = async (datastoreId: string, projectId?: string) => {
const deleteDataStore = async (datastoreId: string, projectId: string) => {
const deleted = await deleteDataStoreApi(rootStore.restApiContext, datastoreId, projectId);
if (deleted) {
dataStores.value = dataStores.value.filter((store) => store.id !== datastoreId);
@@ -50,7 +60,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
return deleted;
};
const updateDataStore = async (datastoreId: string, name: string, projectId?: string) => {
const updateDataStore = async (datastoreId: string, name: string, projectId: string) => {
const updated = await updateDataStoreApi(
rootStore.restApiContext,
datastoreId,
@@ -68,6 +78,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const fetchDataStoreDetails = async (datastoreId: string, projectId: string) => {
const response = await fetchDataStoresApi(rootStore.restApiContext, projectId, undefined, {
projectId,
id: datastoreId,
});
if (response.data.length > 0) {
@@ -104,6 +115,36 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
return newColumn;
};
const fetchDataStoreContent = async (
datastoreId: string,
projectId: string,
page: number,
pageSize: number,
) => {
return await getDataStoreRowsApi(rootStore.restApiContext, datastoreId, projectId, {
skip: (page - 1) * pageSize,
take: pageSize,
});
};
const insertEmptyRow = async (dataStore: DataStore) => {
const emptyRow: DataStoreRow = {};
dataStore.columns.forEach((column) => {
// Set default values based on column type
emptyRow[column.name] = dataStoreTypes.getDefaultValueForType(column.type);
});
return await insertDataStoreRowApi(
rootStore.restApiContext,
dataStore.id,
emptyRow,
dataStore.projectId,
);
};
const upsertRow = async (dataStoreId: string, projectId: string, row: DataStoreRow) => {
return await upsertDataStoreRowsApi(rootStore.restApiContext, dataStoreId, [row], projectId);
};
return {
dataStores,
totalCount,
@@ -114,5 +155,8 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
fetchDataStoreDetails,
fetchOrFindDataStore,
addDataStoreColumn,
fetchDataStoreContent,
insertEmptyRow,
upsertRow,
};
});

View File

@@ -0,0 +1,11 @@
import type { DataStoreValue } from '@/features/dataStore/datastore.types';
export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
return (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value instanceof Date
);
};