feat(editor): Data store UI/UX improvements (no-changelog) (#18587)

Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io>
This commit is contained in:
Milorad FIlipović
2025-08-25 08:46:40 +02:00
committed by GitHub
parent c8dc7d9ab6
commit 802157a329
17 changed files with 335 additions and 151 deletions

View File

@@ -2834,10 +2834,10 @@
"contextual.users.settings.unavailable.button.cloud": "Upgrade now", "contextual.users.settings.unavailable.button.cloud": "Upgrade now",
"contextual.feature.unavailable.title": "Available on the Enterprise Plan", "contextual.feature.unavailable.title": "Available on the Enterprise Plan",
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan", "contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
"dataStore.dataStores": "Data Stores", "dataStore.dataStores": "Data Tables",
"dataStore.empty.label": "You don't have any data stores yet", "dataStore.empty.label": "You don't have any data tables yet",
"dataStore.empty.description": "Once you create data stores for your projects, they will appear here", "dataStore.empty.description": "Once you create data tables for your projects, they will appear here",
"dataStore.empty.button.label": "Create data store in \"{projectName}\"", "dataStore.empty.button.label": "Create data table in \"{projectName}\"",
"dataStore.card.size": "{size}MB", "dataStore.card.size": "{size}MB",
"dataStore.card.column.count": "{count} column | {count} columns", "dataStore.card.column.count": "{count} column | {count} columns",
"dataStore.card.row.count": "{count} record | {count} records", "dataStore.card.row.count": "{count} record | {count} records",
@@ -2846,21 +2846,21 @@
"dataStore.sort.nameAsc": "Sort by name (A-Z)", "dataStore.sort.nameAsc": "Sort by name (A-Z)",
"dataStore.sort.nameDesc": "Sort by name (Z-A)", "dataStore.sort.nameDesc": "Sort by name (Z-A)",
"dataStore.search.placeholder": "Search", "dataStore.search.placeholder": "Search",
"dataStore.error.fetching": "Error loading data stores", "dataStore.error.fetching": "Error loading data tables",
"dataStore.add.title": "Create data store", "dataStore.add.title": "Create data table",
"dataStore.add.description": "Set up a new data store to organize and manage your data.", "dataStore.add.description": "Set up a new data table to organize and manage your data.",
"dataStore.add.button.label": "Create Data Store", "dataStore.add.button.label": "Create data table",
"dataStore.add.input.name.label": "Data Store Name", "dataStore.add.input.name.label": "Data Table Name",
"dataStore.add.input.name.placeholder": "Enter data store name", "dataStore.add.input.name.placeholder": "Enter data table name",
"dataStore.add.error": "Error creating data store", "dataStore.add.error": "Error creating data table",
"dataStore.delete.confirm.title": "Delete data store", "dataStore.delete.confirm.title": "Delete data table",
"dataStore.delete.confirm.message": "Are you sure you want to delete the data store \"{name}\"? This action cannot be undone.", "dataStore.delete.confirm.message": "Are you sure you want to delete the data table \"{name}\"? This action cannot be undone.",
"dataStore.delete.error": "Error deleting data store", "dataStore.delete.error": "Error deleting data table",
"dataStore.rename.error": "Error renaming data store", "dataStore.rename.error": "Error renaming data table",
"dataStore.getDetails.error": "Error fetching data store details", "dataStore.getDetails.error": "Error fetching data table details",
"dataStore.notFound": "Data store not found", "dataStore.notFound": "Data table not found",
"dataStore.noColumns.heading": "No columns yet", "dataStore.noColumns.heading": "No columns yet",
"dataStore.noColumns.description": "Add columns to start storing data in this data store.", "dataStore.noColumns.description": "Add columns to start storing data in this data table.",
"dataStore.noColumns.button.label": "Add first column", "dataStore.noColumns.button.label": "Add first column",
"dataStore.addColumn.label": "Add Column", "dataStore.addColumn.label": "Add Column",
"dataStore.addColumn.nameInput.label": "@:_reusableBaseText.name", "dataStore.addColumn.nameInput.label": "@:_reusableBaseText.name",

View File

@@ -44,6 +44,7 @@ const initialize = async () => {
const response = await dataStoreStore.fetchOrFindDataStore(props.id, props.projectId); const response = await dataStoreStore.fetchOrFindDataStore(props.id, props.projectId);
if (response) { if (response) {
dataStore.value = response; dataStore.value = response;
documentTitle.set(`${i18n.baseText('dataStore.dataStores')} > ${response.name}`);
} else { } else {
await showErrorAndGoBackToList(new Error(i18n.baseText('dataStore.notFound'))); await showErrorAndGoBackToList(new Error(i18n.baseText('dataStore.notFound')));
} }

View File

@@ -120,24 +120,6 @@ const onProjectHeaderAction = (action: string) => {
} }
}; };
const onCardRename = async (payload: { dataStore: DataStoreResource }) => {
try {
const updated = await dataStoreStore.updateDataStore(
payload.dataStore.id,
payload.dataStore.name,
payload.dataStore.projectId,
);
if (!updated) {
toast.showError(
new Error(i18n.baseText('generic.unknownError')),
i18n.baseText('dataStore.rename.error'),
);
}
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.rename.error'));
}
};
onMounted(() => { onMounted(() => {
documentTitle.set(i18n.baseText('dataStore.dataStores')); documentTitle.set(i18n.baseText('dataStore.dataStores'));
}); });
@@ -190,7 +172,6 @@ onMounted(() => {
:data-store="data as DataStoreResource" :data-store="data as DataStoreResource"
:show-ownership-badge="projectPages.isOverviewSubPage" :show-ownership-badge="projectPages.isOverviewSubPage"
:read-only="readOnlyEnv" :read-only="readOnlyEnv"
@rename="onCardRename"
/> />
</template> </template>
</ResourcesListLayout> </ResourcesListLayout>

View File

@@ -64,6 +64,7 @@ const renderComponent = createComponentRenderer(DataStoreActions, {
props: { props: {
dataStore: mockDataStore, dataStore: mockDataStore,
isReadOnly: false, isReadOnly: false,
location: 'breadcrumbs',
}, },
}); });
@@ -236,4 +237,50 @@ describe('DataStoreActions', () => {
'Something went wrong while deleting the data store.', 'Something went wrong while deleting the data store.',
); );
}); });
describe('rename action visibility', () => {
it('should show rename action when location is breadcrumbs', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
dataStore: mockDataStore,
isReadOnly: false,
location: 'breadcrumbs',
},
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Check that rename action is present
expect(queryByTestId(`action-${DATA_STORE_CARD_ACTIONS.RENAME}`)).toBeInTheDocument();
});
it('should not show rename action when location is card', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
dataStore: mockDataStore,
isReadOnly: false,
location: 'card',
},
pinia: createTestingPinia({
initialState: {},
stubActions: false,
}),
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Check that rename action is NOT present
expect(queryByTestId(`action-${DATA_STORE_CARD_ACTIONS.RENAME}`)).not.toBeInTheDocument();
// But delete action should still be present
expect(queryByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`)).toBeInTheDocument();
});
});
}); });

View File

@@ -12,6 +12,7 @@ import { useToast } from '@/composables/useToast';
type Props = { type Props = {
dataStore: DataStore; dataStore: DataStore;
isReadOnly?: boolean; isReadOnly?: boolean;
location: 'card' | 'breadcrumbs';
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -34,18 +35,23 @@ const i18n = useI18n();
const message = useMessage(); const message = useMessage();
const toast = useToast(); const toast = useToast();
const actions = computed<Array<UserAction<IUser>>>(() => [ const actions = computed<Array<UserAction<IUser>>>(() => {
{ const availableActions = [
label: i18n.baseText('generic.rename'), {
value: DATA_STORE_CARD_ACTIONS.RENAME, label: i18n.baseText('generic.delete'),
disabled: props.isReadOnly, value: DATA_STORE_CARD_ACTIONS.DELETE,
}, disabled: props.isReadOnly,
{ },
label: i18n.baseText('generic.delete'), ];
value: DATA_STORE_CARD_ACTIONS.DELETE, if (props.location === 'breadcrumbs') {
disabled: props.isReadOnly, availableActions.unshift({
}, label: i18n.baseText('generic.rename'),
]); value: DATA_STORE_CARD_ACTIONS.RENAME,
disabled: props.isReadOnly,
});
}
return availableActions;
});
const onAction = async (action: string) => { const onAction = async (action: string) => {
switch (action) { switch (action) {

View File

@@ -153,7 +153,7 @@ describe('DataStoreBreadcrumbs', () => {
const datastoresLink = getByText('Data Stores'); const datastoresLink = getByText('Data Stores');
await userEvent.click(datastoresLink); await userEvent.click(datastoresLink);
expect(mockRouter.push).toHaveBeenCalledWith('/projects/project-1/datastores'); expect(mockRouter.push).toHaveBeenCalledWith('/projects/project-1/datatables');
}); });
it('should render DataStoreActions component that can trigger navigation', () => { it('should render DataStoreActions component that can trigger navigation', () => {

View File

@@ -39,7 +39,7 @@ const breadcrumbs = computed<PathItem[]>(() => {
{ {
id: 'datastores', id: 'datastores',
label: i18n.baseText('dataStore.dataStores'), label: i18n.baseText('dataStore.dataStores'),
href: `/projects/${project.value.id}/datastores`, href: `/projects/${project.value.id}/datatables`,
}, },
]; ];
}); });
@@ -118,7 +118,12 @@ watch(
</template> </template>
</n8n-breadcrumbs> </n8n-breadcrumbs>
<div :class="$style['data-store-actions']"> <div :class="$style['data-store-actions']">
<DataStoreActions :data-store="props.dataStore" @rename="onRename" @on-deleted="onDelete" /> <DataStoreActions
:data-store="props.dataStore"
location="breadcrumbs"
@rename="onRename"
@on-deleted="onDelete"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -7,7 +7,7 @@ import type { IUser } from '@n8n/rest-api-client/api/users';
vi.mock('vue-router', () => { vi.mock('vue-router', () => {
const push = vi.fn(); const push = vi.fn();
const resolve = vi.fn().mockReturnValue({ href: '/projects/1/datastores/1' }); const resolve = vi.fn().mockReturnValue({ href: '/projects/1/datatables/1' });
return { return {
useRouter: vi.fn().mockReturnValue({ useRouter: vi.fn().mockReturnValue({
push, push,
@@ -55,7 +55,7 @@ const renderComponent = createComponentRenderer(DataStoreCard, {
href() { href() {
// Generate href from the route object // Generate href from the route object
if (this.to && typeof this.to === 'object') { if (this.to && typeof this.to === 'object') {
return `/projects/${this.to.params.projectId}/datastores/${this.to.params.id}`; return `/projects/${this.to.params.projectId}/datatables/${this.to.params.id}`;
} }
return '#'; return '#';
}, },
@@ -83,7 +83,7 @@ describe('DataStoreCard', () => {
it('should render data store info correctly', () => { it('should render data store info correctly', () => {
const { getByTestId } = renderComponent(); const { getByTestId } = renderComponent();
expect(getByTestId('data-store-card-icon')).toBeInTheDocument(); expect(getByTestId('data-store-card-icon')).toBeInTheDocument();
expect(getByTestId('datastore-name-input')).toHaveTextContent(DEFAULT_DATA_STORE.name); expect(getByTestId('data-store-card-name')).toHaveTextContent(DEFAULT_DATA_STORE.name);
expect(getByTestId('data-store-card-record-count')).toBeInTheDocument(); expect(getByTestId('data-store-card-record-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-column-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-last-updated')).toHaveTextContent('Last updated');
@@ -110,7 +110,7 @@ describe('DataStoreCard', () => {
expect(link).toBeInTheDocument(); expect(link).toBeInTheDocument();
expect(link).toHaveAttribute( expect(link).toHaveAttribute(
'href', 'href',
`/projects/${DEFAULT_DATA_STORE.projectId}/datastores/${DEFAULT_DATA_STORE.id}`, `/projects/${DEFAULT_DATA_STORE.projectId}/datatables/${DEFAULT_DATA_STORE.id}`,
); );
}); });

View File

@@ -2,7 +2,7 @@
import type { DataStoreResource } from '@/features/dataStore/types'; import type { DataStoreResource } from '@/features/dataStore/types';
import { DATA_STORE_DETAILS } from '@/features/dataStore/constants'; import { DATA_STORE_DETAILS } from '@/features/dataStore/constants';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { computed, useTemplateRef } from 'vue'; import { computed } from 'vue';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue'; import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
type Props = { type Props = {
@@ -19,16 +19,6 @@ const props = withDefaults(defineProps<Props>(), {
showOwnershipBadge: false, showOwnershipBadge: false,
}); });
const emit = defineEmits<{
rename: [
value: {
dataStore: DataStoreResource;
},
];
}>();
const renameInput = useTemplateRef<{ forceFocus?: () => void }>('renameInput');
const dataStoreRoute = computed(() => { const dataStoreRoute = computed(() => {
return { return {
name: DATA_STORE_DETAILS, name: DATA_STORE_DETAILS,
@@ -38,24 +28,6 @@ const dataStoreRoute = computed(() => {
}, },
}; };
}); });
const onRename = () => {
// Focus rename input if the action is rename
// We need this timeout to ensure action toggle is closed before focusing
if (renameInput.value && typeof renameInput.value.forceFocus === 'function') {
setTimeout(() => {
renameInput.value?.forceFocus?.();
}, 100);
}
};
const onNameSubmit = (name: string) => {
if (props.dataStore.name === name) return;
emit('rename', {
dataStore: { ...props.dataStore, name },
});
};
</script> </script>
<template> <template>
<div data-test-id="data-store-card"> <div data-test-id="data-store-card">
@@ -72,17 +44,9 @@ const onNameSubmit = (name: string) => {
</template> </template>
<template #header> <template #header>
<div :class="$style['card-header']" @click.prevent> <div :class="$style['card-header']" @click.prevent>
<N8nInlineTextEdit <n8n-text tag="h2" bold data-test-id="data-store-card-name">
ref="renameInput" {{ props.dataStore.name }}
data-test-id="datastore-name-input" </n8n-text>
:placeholder="i18n.baseText('dataStore.add.input.name.label')"
:class="$style['card-name']"
:model-value="props.dataStore.name"
:max-length="50"
:read-only="props.readOnly"
:disabled="props.readOnly"
@update:model-value="onNameSubmit"
/>
<N8nBadge v-if="props.readOnly" class="ml-3xs" theme="tertiary" bold> <N8nBadge v-if="props.readOnly" class="ml-3xs" theme="tertiary" bold>
{{ i18n.baseText('workflows.item.readonly') }} {{ i18n.baseText('workflows.item.readonly') }}
</N8nBadge> </N8nBadge>
@@ -139,7 +103,7 @@ const onNameSubmit = (name: string) => {
<DataStoreActions <DataStoreActions
:data-store="props.dataStore" :data-store="props.dataStore"
:is-read-only="props.readOnly" :is-read-only="props.readOnly"
@rename="onRename" location="card"
/> />
</div> </div>
</template> </template>
@@ -158,12 +122,6 @@ const onNameSubmit = (name: string) => {
} }
} }
.card-name {
color: $custom-font-dark;
font-size: var(--font-size-m);
margin-bottom: var(--spacing-5xs);
}
.card-icon { .card-icon {
flex-shrink: 0; flex-shrink: 0;
color: var(--color-text-base); color: var(--color-text-base);

View File

@@ -73,7 +73,7 @@ const validateName = () => {
} }
}; };
const onInput = debounce(validateName, { debounceTime: 300 }); const onInput = debounce(validateName, { debounceTime: 100 });
</script> </script>
<template> <template>

View File

@@ -70,6 +70,19 @@ vi.mock('@/composables/useToast', () => ({
}), }),
})); }));
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => {
const translations: Record<string, string> = {
'dataStore.addRow.label': 'Add Row',
'dataStore.addRow.disabled.tooltip': 'Add a column first',
};
return translations[key] || key;
},
}),
}));
vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({ vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
useDataStoreTypes: () => ({ useDataStoreTypes: () => ({
mapToAGCellType: (type: string) => { mapToAGCellType: (type: string) => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref, useTemplateRef } from 'vue';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import type { import type {
DataStore, DataStore,
@@ -16,6 +16,13 @@ import type {
ValueGetterParams, ValueGetterParams,
RowSelectionOptions, RowSelectionOptions,
CellValueChangedEvent, CellValueChangedEvent,
GetRowIdParams,
ICellRendererParams,
CellEditRequestEvent,
CellClickedEvent,
ValueSetterParams,
CellEditingStartedEvent,
CellEditingStoppedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { import {
ModuleRegistry, ModuleRegistry,
@@ -37,11 +44,14 @@ import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumn
import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { DEFAULT_ID_COLUMN_NAME, EMPTY_VALUE, NULL_VALUE } from '@/features/dataStore/constants';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants'; import { MODAL_CONFIRM } from '@/constants';
import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue'; import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
import { DEFAULT_ID_COLUMN_NAME, NO_TABLE_YET_MESSAGE } from '@/features/dataStore/constants';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes'; import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import { onClickOutside } from '@vueuse/core';
// Register only the modules we actually use // Register only the modules we actually use
ModuleRegistry.registerModules([ ModuleRegistry.registerModules([
@@ -81,15 +91,22 @@ const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]); const colDefs = ref<ColDef[]>([]);
const rowData = ref<DataStoreRow[]>([]); const rowData = ref<DataStoreRow[]>([]);
const rowSelection: RowSelectionOptions | 'single' | 'multiple' = { const rowSelection: RowSelectionOptions | 'single' | 'multiple' = {
mode: 'singleRow', mode: 'multiRow',
enableClickSelection: true, enableClickSelection: false,
checkboxes: false, checkboxes: true,
}; };
const contentLoading = ref(false); const contentLoading = ref(false);
// Track the last focused cell so we can start editing when users click on it
// AG Grid doesn't provide cell blur event so we need to reset this manually
const lastFocusedCell = ref<{ rowIndex: number; colId: string } | null>(null);
const isTextEditorOpen = ref(false);
const gridContainer = useTemplateRef('gridContainer');
// Shared config for all columns // Shared config for all columns
const defaultColumnDef = { const defaultColumnDef: ColDef = {
flex: 1, flex: 1,
sortable: false, sortable: false,
filter: false, filter: false,
@@ -184,6 +201,7 @@ const onDeleteColumn = async (columnId: string) => {
} }
}; };
// TODO: Split this up to create column def based on type
const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {}) => { const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {}) => {
const columnDef: ColDef = { const columnDef: ColDef = {
colId: col.id, colId: col.id,
@@ -192,6 +210,7 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
editable: true, editable: true,
resizable: true, resizable: true,
headerComponent: ColumnHeader, headerComponent: ColumnHeader,
cellEditorPopup: false,
headerComponentParams: { onDelete: onDeleteColumn }, headerComponentParams: { onDelete: onDeleteColumn },
...extraProps, ...extraProps,
cellDataType: dataStoreTypes.mapToAGCellType(col.type), cellDataType: dataStoreTypes.mapToAGCellType(col.type),
@@ -209,15 +228,61 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
} }
return params.data?.[col.name]; return params.data?.[col.name];
}, },
cellRendererSelector: (params: ICellRendererParams) => {
let rowValue = params.data?.[col.name];
// When adding new column, rowValue is undefined (same below, in string cell editor)
if (rowValue === undefined) {
rowValue = null;
}
// Custom renderer for null or empty values
if (rowValue === null) {
return { component: NullEmptyCellRenderer, params: { value: NULL_VALUE } };
}
if (rowValue === '') {
return { component: NullEmptyCellRenderer, params: { value: EMPTY_VALUE } };
}
// Fallback to default cell renderer
return undefined;
},
}; };
// Enable large text editor for text columns // Enable large text editor for text columns
if (col.type === 'string') { if (col.type === 'string') {
columnDef.cellEditor = 'agLargeTextCellEditor'; columnDef.cellEditor = 'agLargeTextCellEditor';
columnDef.cellEditorPopup = true; // Provide initial value for the editor, otherwise agLargeTextCellEditor breaks
columnDef.cellEditorParams = (params: CellEditRequestEvent<DataStoreRow>) => ({
value: params.value ?? '',
});
columnDef.valueSetter = (params: ValueSetterParams<DataStoreRow>) => {
let originalValue = params.data[col.name];
if (originalValue === undefined) {
originalValue = null;
}
let newValue = params.newValue;
if (!isDataStoreValue(newValue)) {
return false;
}
// Make sure not to trigger update if cell content is not set and value was null
if (originalValue === null && newValue === '') {
return false;
}
// When clearing editor content, set value to empty string
if (isTextEditorOpen.value && newValue === null) {
newValue = '';
}
// Otherwise update the value
params.data[col.name] = newValue;
return true;
};
} }
// Setup date editor // Setup date editor
if (col.type === 'date') { if (col.type === 'date') {
columnDef.cellEditor = 'agDateCellEditor'; columnDef.cellEditor = 'agDateCellEditor';
columnDef.cellEditorPopup = true;
} }
return columnDef; return columnDef;
}; };
@@ -252,16 +317,20 @@ const onAddRowClick = async () => {
if (currentPage.value * pageSize.value < totalItems.value) { if (currentPage.value * pageSize.value < totalItems.value) {
await setCurrentPage(Math.ceil(totalItems.value / pageSize.value)); await setCurrentPage(Math.ceil(totalItems.value / pageSize.value));
} }
const inserted = await dataStoreStore.insertEmptyRow(props.dataStore); contentLoading.value = true;
if (!inserted) {
throw new Error(i18n.baseText('generic.unknownError'));
}
emit('toggleSave', true); emit('toggleSave', true);
await fetchDataStoreContent(); const newRowId = await dataStoreStore.insertEmptyRow(props.dataStore);
const newRow: DataStoreRow = { id: newRowId };
// Add nulls for the rest of the columns
props.dataStore.columns.forEach((col) => {
newRow[col.name] = null;
});
rows.value.push(newRow);
} catch (error) { } catch (error) {
toast.showError(error, i18n.baseText('dataStore.addRow.error')); toast.showError(error, i18n.baseText('dataStore.addRow.error'));
} finally { } finally {
emit('toggleSave', false); emit('toggleSave', false);
contentLoading.value = false;
} }
}; };
@@ -288,21 +357,67 @@ const initColumnDefinitions = () => {
]; ];
}; };
const onCellValueChanged = async (params: CellValueChangedEvent) => { const onCellValueChanged = async (params: CellValueChangedEvent<DataStoreRow>) => {
const { data, api } = params; const { data, api, oldValue, colDef } = params;
const value = params.data[colDef.field!];
if (value === undefined || value === oldValue) {
return;
}
if (typeof data.id !== 'number') {
throw new Error('Expected row id to be a number');
}
const fieldName = String(colDef.field);
const id = data.id;
try { try {
emit('toggleSave', true); emit('toggleSave', true);
await dataStoreStore.upsertRow(props.dataStore.id, props.dataStore.projectId, data); await dataStoreStore.updateRow(props.dataStore.id, props.dataStore.projectId, id, {
[fieldName]: value,
});
} catch (error) { } catch (error) {
// Revert cell to original value if the update fails // Revert cell to original value if the update fails
api.undoCellEditing(); const validOldValue = isDataStoreValue(oldValue) ? oldValue : null;
const revertedData: DataStoreRow = { ...data, [fieldName]: validOldValue };
api.applyTransaction({
update: [revertedData],
});
toast.showError(error, i18n.baseText('dataStore.updateRow.error')); toast.showError(error, i18n.baseText('dataStore.updateRow.error'));
} finally { } finally {
emit('toggleSave', false); emit('toggleSave', false);
} }
}; };
// Start editing when users click on already focused cells
const onCellClicked = (params: CellClickedEvent<DataStoreRow>) => {
const clickedCellColumn = params.column.getColId();
const clickedCellRow = params.rowIndex;
// Skip if rowIndex is null
if (clickedCellRow === null) return;
// Check if this is the same cell that was focused before this click
const wasAlreadyFocused =
lastFocusedCell.value &&
lastFocusedCell.value.rowIndex === clickedCellRow &&
lastFocusedCell.value.colId === clickedCellColumn;
if (wasAlreadyFocused && params.column.getColDef()?.editable) {
// Cell was already selected, start editing
params.api.startEditingCell({
rowIndex: clickedCellRow,
colKey: clickedCellColumn,
});
}
// Update the last focused cell for next click
lastFocusedCell.value = {
rowIndex: clickedCellRow,
colId: clickedCellColumn,
};
};
const fetchDataStoreContent = async () => { const fetchDataStoreContent = async () => {
try { try {
contentLoading.value = true; contentLoading.value = true;
@@ -316,11 +431,7 @@ const fetchDataStoreContent = async () => {
totalItems.value = fetchedRows.count; totalItems.value = fetchedRows.count;
rowData.value = rows.value; rowData.value = rows.value;
} catch (error) { } catch (error) {
// TODO: We currently don't create user tables until user columns or rows are added toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
// 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 { } finally {
contentLoading.value = false; contentLoading.value = false;
if (gridApi.value) { if (gridApi.value) {
@@ -329,6 +440,14 @@ const fetchDataStoreContent = async () => {
} }
}; };
onClickOutside(gridContainer, () => {
resetLastFocusedCell();
});
const resetLastFocusedCell = () => {
lastFocusedCell.value = null;
};
const initialize = async () => { const initialize = async () => {
initColumnDefinitions(); initColumnDefinitions();
await fetchDataStoreContent(); await fetchDataStoreContent();
@@ -337,11 +456,25 @@ const initialize = async () => {
onMounted(async () => { onMounted(async () => {
await initialize(); await initialize();
}); });
const onCellEditingStarted = (params: CellEditingStartedEvent<DataStoreRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = true;
} else {
isTextEditorOpen.value = false;
}
};
const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = false;
}
};
</script> </script>
<template> <template>
<div :class="$style.wrapper"> <div :class="$style.wrapper">
<div :class="$style['grid-container']" data-test-id="data-store-grid"> <div ref="gridContainer" :class="$style['grid-container']" data-test-id="data-store-grid">
<AgGridVue <AgGridVue
style="width: 100%" style="width: 100%"
:row-data="rowData" :row-data="rowData"
@@ -355,13 +488,17 @@ onMounted(async () => {
:suppress-drag-leave-hides-columns="true" :suppress-drag-leave-hides-columns="true"
:loading="contentLoading" :loading="contentLoading"
:row-selection="rowSelection" :row-selection="rowSelection"
:get-row-id="(params) => String(params.data.id)" :get-row-id="(params: GetRowIdParams) => String(params.data.id)"
:single-click-edit="true"
:stop-editing-when-cells-lose-focus="true" :stop-editing-when-cells-lose-focus="true"
:undo-redo-cell-editing="true" :undo-redo-cell-editing="true"
@grid-ready="onGridReady" @grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged" @cell-value-changed="onCellValueChanged"
@column-moved="onColumnMoved" @column-moved="onColumnMoved"
@cell-clicked="onCellClicked"
@cell-editing-started="onCellEditingStarted"
@cell-editing-stopped="onCellEditingStopped"
@column-header-clicked="resetLastFocusedCell"
@selection-changed="resetLastFocusedCell"
/> />
<AddColumnPopover <AddColumnPopover
:data-store="props.dataStore" :data-store="props.dataStore"
@@ -430,6 +567,15 @@ onMounted(async () => {
:global(.ag-header-cell-resize) { :global(.ag-header-cell-resize) {
width: var(--spacing-4xs); width: var(--spacing-4xs);
} }
// Don't show borders for the checkbox cells
:global(.ag-cell[col-id='ag-Grid-SelectionColumn']) {
border: none;
}
:global(.ag-cell[col-id='ag-Grid-SelectionColumn'].ag-cell-focus) {
outline: none;
}
} }
.add-column-popover { .add-column-popover {

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const props = defineProps<{
params: {
value: string;
};
}>();
</script>
<template>
<span class="n8n-empty-value">{{ props.params.value }}</span>
</template>
<style lang="scss">
.n8n-empty-value {
font-style: italic;
color: var(--color-text-lighter);
}
</style>

View File

@@ -20,8 +20,9 @@ export const MAX_COLUMN_NAME_LENGTH = 128;
export const COLUMN_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; 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 export const MIN_LOADING_TIME = 500; // ms
export const NULL_VALUE = 'Null';
export const EMPTY_VALUE = 'Empty';
export const DATA_STORE_MODULE_NAME = 'data-store'; export const DATA_STORE_MODULE_NAME = 'data-store';

View File

@@ -151,7 +151,7 @@ export const insertDataStoreRowApi = async (
row: DataStoreRow, row: DataStoreRow,
projectId: string, projectId: string,
) => { ) => {
return await makeRestApiRequest<boolean>( return await makeRestApiRequest<number[]>(
context, context,
'POST', 'POST',
`/projects/${projectId}/data-stores/${dataStoreId}/insert`, `/projects/${projectId}/data-stores/${dataStoreId}/insert`,
@@ -161,20 +161,20 @@ export const insertDataStoreRowApi = async (
); );
}; };
export const upsertDataStoreRowsApi = async ( export const updateDataStoreRowsApi = async (
context: IRestApiContext, context: IRestApiContext,
dataStoreId: string, dataStoreId: string,
rows: DataStoreRow[], rowId: number,
rowData: DataStoreRow,
projectId: string, projectId: string,
matchFields: string[] = ['id'],
) => { ) => {
return await makeRestApiRequest<boolean>( return await makeRestApiRequest<boolean>(
context, context,
'POST', 'PATCH',
`/projects/${projectId}/data-stores/${dataStoreId}/upsert`, `/projects/${projectId}/data-stores/${dataStoreId}/rows`,
{ {
rows, filter: { id: rowId },
matchFields, data: rowData,
}, },
); );
}; };

View File

@@ -12,7 +12,7 @@ import {
moveDataStoreColumnApi, moveDataStoreColumnApi,
getDataStoreRowsApi, getDataStoreRowsApi,
insertDataStoreRowApi, insertDataStoreRowApi,
upsertDataStoreRowsApi, updateDataStoreRowsApi,
} from '@/features/dataStore/dataStore.api'; } from '@/features/dataStore/dataStore.api';
import type { import type {
DataStore, DataStore,
@@ -20,14 +20,11 @@ import type {
DataStoreRow, DataStoreRow,
} from '@/features/dataStore/datastore.types'; } from '@/features/dataStore/datastore.types';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const projectStore = useProjectsStore(); const projectStore = useProjectsStore();
const dataStoreTypes = useDataStoreTypes();
const dataStores = ref<DataStore[]>([]); const dataStores = ref<DataStore[]>([]);
const totalCount = ref(0); const totalCount = ref(0);
@@ -185,19 +182,30 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const insertEmptyRow = async (dataStore: DataStore) => { const insertEmptyRow = async (dataStore: DataStore) => {
const emptyRow: DataStoreRow = {}; const emptyRow: DataStoreRow = {};
dataStore.columns.forEach((column) => { dataStore.columns.forEach((column) => {
// Set default values based on column type emptyRow[column.name] = null;
emptyRow[column.name] = dataStoreTypes.getDefaultValueForType(column.type);
}); });
return await insertDataStoreRowApi( const inserted = await insertDataStoreRowApi(
rootStore.restApiContext, rootStore.restApiContext,
dataStore.id, dataStore.id,
emptyRow, emptyRow,
dataStore.projectId, dataStore.projectId,
); );
return inserted[0];
}; };
const upsertRow = async (dataStoreId: string, projectId: string, row: DataStoreRow) => { const updateRow = async (
return await upsertDataStoreRowsApi(rootStore.restApiContext, dataStoreId, [row], projectId); dataStoreId: string,
projectId: string,
rowId: number,
rowData: DataStoreRow,
) => {
return await updateDataStoreRowsApi(
rootStore.restApiContext,
dataStoreId,
rowId,
rowData,
projectId,
);
}; };
return { return {
@@ -214,6 +222,6 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
moveDataStoreColumn, moveDataStoreColumn,
fetchDataStoreContent, fetchDataStoreContent,
insertEmptyRow, insertEmptyRow,
upsertRow, updateRow,
}; };
}); });

View File

@@ -29,7 +29,7 @@ export const DataStoreModule: FrontendModuleDescription = {
routes: [ routes: [
{ {
name: DATA_STORE_VIEW, name: DATA_STORE_VIEW,
path: '/home/datastores', path: '/home/datatables',
components: { components: {
default: DataStoreView, default: DataStoreView,
sidebar: MainSidebar, sidebar: MainSidebar,
@@ -40,7 +40,7 @@ export const DataStoreModule: FrontendModuleDescription = {
}, },
{ {
name: PROJECT_DATA_STORES, name: PROJECT_DATA_STORES,
path: 'datastores', path: 'datatables',
props: true, props: true,
components: { components: {
default: DataStoreView, default: DataStoreView,
@@ -53,7 +53,7 @@ export const DataStoreModule: FrontendModuleDescription = {
}, },
{ {
name: DATA_STORE_DETAILS, name: DATA_STORE_DETAILS,
path: 'datastores/:id', path: 'datatables/:id',
props: true, props: true,
components: { components: {
default: DataStoreDetailsView, default: DataStoreDetailsView,