diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 3d53fb4122..9d39423a2f 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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 LDAP in the Docs", diff --git a/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue index 6d34f46e53..d5e6526dae 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreDetailsView.vue @@ -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(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 () => {
+
+ + {{ i18n.baseText('generic.saving') }}... +
- +
@@ -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); +} diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts index f95853f806..db9c9323be 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.test.ts @@ -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 diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue index 01c44860a8..e37d3c38db 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/components/dataGrid/DataStoreTable.vue @@ -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(); +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(null); const colDefs = ref([]); const rowData = ref([]); +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([]); + 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) => { + // 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(); }); @@ -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" /> { />
- + + + @@ -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); diff --git a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts index dfe57b3d19..3686ca861b 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/composables/useDataStoreTypes.ts @@ -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 = { @@ -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, }; }; diff --git a/packages/frontend/editor-ui/src/features/dataStore/constants.ts b/packages/frontend/editor-ui/src/features/dataStore/constants.ts index afe8c8f8c2..04119ba48d 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/constants.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/constants.ts @@ -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 diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts index 1c7e4a8b04..6d506c0fb0 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts @@ -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( @@ -52,7 +53,7 @@ export const createDataStoreApi = async ( export const deleteDataStoreApi = async ( context: IRestApiContext, dataStoreId: string, - projectId?: string, + projectId: string, ) => { return await makeRestApiRequest( context, @@ -69,7 +70,7 @@ export const updateDataStoreApi = async ( context: IRestApiContext, dataStoreId: string, name: string, - projectId?: string, + projectId: string, ) => { return await makeRestApiRequest( 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( + 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( + context, + 'POST', + `/projects/${projectId}/data-stores/${dataStoreId}/upsert`, + { + rows, + matchFields, + }, + ); +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts index c3b94f36ae..01ca0f8cc0 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts @@ -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([]); 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, }; }); diff --git a/packages/frontend/editor-ui/src/features/dataStore/typeGuards.ts b/packages/frontend/editor-ui/src/features/dataStore/typeGuards.ts new file mode 100644 index 0000000000..c5f9e3ab11 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/typeGuards.ts @@ -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 + ); +};