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

View File

@@ -44,6 +44,7 @@ const initialize = async () => {
const response = await dataStoreStore.fetchOrFindDataStore(props.id, props.projectId);
if (response) {
dataStore.value = response;
documentTitle.set(`${i18n.baseText('dataStore.dataStores')} > ${response.name}`);
} else {
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(() => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
});
@@ -190,7 +172,6 @@ onMounted(() => {
:data-store="data as DataStoreResource"
:show-ownership-badge="projectPages.isOverviewSubPage"
:read-only="readOnlyEnv"
@rename="onCardRename"
/>
</template>
</ResourcesListLayout>

View File

@@ -64,6 +64,7 @@ const renderComponent = createComponentRenderer(DataStoreActions, {
props: {
dataStore: mockDataStore,
isReadOnly: false,
location: 'breadcrumbs',
},
});
@@ -236,4 +237,50 @@ describe('DataStoreActions', () => {
'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 = {
dataStore: DataStore;
isReadOnly?: boolean;
location: 'card' | 'breadcrumbs';
};
const props = withDefaults(defineProps<Props>(), {
@@ -34,18 +35,23 @@ const i18n = useI18n();
const message = useMessage();
const toast = useToast();
const actions = computed<Array<UserAction<IUser>>>(() => [
{
label: i18n.baseText('generic.rename'),
value: DATA_STORE_CARD_ACTIONS.RENAME,
disabled: props.isReadOnly,
},
{
label: i18n.baseText('generic.delete'),
value: DATA_STORE_CARD_ACTIONS.DELETE,
disabled: props.isReadOnly,
},
]);
const actions = computed<Array<UserAction<IUser>>>(() => {
const availableActions = [
{
label: i18n.baseText('generic.delete'),
value: DATA_STORE_CARD_ACTIONS.DELETE,
disabled: props.isReadOnly,
},
];
if (props.location === 'breadcrumbs') {
availableActions.unshift({
label: i18n.baseText('generic.rename'),
value: DATA_STORE_CARD_ACTIONS.RENAME,
disabled: props.isReadOnly,
});
}
return availableActions;
});
const onAction = async (action: string) => {
switch (action) {

View File

@@ -153,7 +153,7 @@ describe('DataStoreBreadcrumbs', () => {
const datastoresLink = getByText('Data Stores');
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', () => {

View File

@@ -39,7 +39,7 @@ const breadcrumbs = computed<PathItem[]>(() => {
{
id: 'datastores',
label: i18n.baseText('dataStore.dataStores'),
href: `/projects/${project.value.id}/datastores`,
href: `/projects/${project.value.id}/datatables`,
},
];
});
@@ -118,7 +118,12 @@ watch(
</template>
</n8n-breadcrumbs>
<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>
</template>

View File

@@ -7,7 +7,7 @@ import type { IUser } from '@n8n/rest-api-client/api/users';
vi.mock('vue-router', () => {
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 {
useRouter: vi.fn().mockReturnValue({
push,
@@ -55,7 +55,7 @@ const renderComponent = createComponentRenderer(DataStoreCard, {
href() {
// Generate href from the route 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 '#';
},
@@ -83,7 +83,7 @@ describe('DataStoreCard', () => {
it('should render data store info correctly', () => {
const { getByTestId } = renderComponent();
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-column-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-last-updated')).toHaveTextContent('Last updated');
@@ -110,7 +110,7 @@ describe('DataStoreCard', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'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 { DATA_STORE_DETAILS } from '@/features/dataStore/constants';
import { useI18n } from '@n8n/i18n';
import { computed, useTemplateRef } from 'vue';
import { computed } from 'vue';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
type Props = {
@@ -19,16 +19,6 @@ const props = withDefaults(defineProps<Props>(), {
showOwnershipBadge: false,
});
const emit = defineEmits<{
rename: [
value: {
dataStore: DataStoreResource;
},
];
}>();
const renameInput = useTemplateRef<{ forceFocus?: () => void }>('renameInput');
const dataStoreRoute = computed(() => {
return {
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>
<template>
<div data-test-id="data-store-card">
@@ -72,17 +44,9 @@ const onNameSubmit = (name: string) => {
</template>
<template #header>
<div :class="$style['card-header']" @click.prevent>
<N8nInlineTextEdit
ref="renameInput"
data-test-id="datastore-name-input"
: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"
/>
<n8n-text tag="h2" bold data-test-id="data-store-card-name">
{{ props.dataStore.name }}
</n8n-text>
<N8nBadge v-if="props.readOnly" class="ml-3xs" theme="tertiary" bold>
{{ i18n.baseText('workflows.item.readonly') }}
</N8nBadge>
@@ -139,7 +103,7 @@ const onNameSubmit = (name: string) => {
<DataStoreActions
:data-store="props.dataStore"
:is-read-only="props.readOnly"
@rename="onRename"
location="card"
/>
</div>
</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 {
flex-shrink: 0;
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>
<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', () => ({
useDataStoreTypes: () => ({
mapToAGCellType: (type: string) => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { onMounted, ref, useTemplateRef } from 'vue';
import orderBy from 'lodash/orderBy';
import type {
DataStore,
@@ -16,6 +16,13 @@ import type {
ValueGetterParams,
RowSelectionOptions,
CellValueChangedEvent,
GetRowIdParams,
ICellRendererParams,
CellEditRequestEvent,
CellClickedEvent,
ValueSetterParams,
CellEditingStartedEvent,
CellEditingStoppedEvent,
} from 'ag-grid-community';
import {
ModuleRegistry,
@@ -37,11 +44,14 @@ import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumn
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { DEFAULT_ID_COLUMN_NAME, EMPTY_VALUE, NULL_VALUE } from '@/features/dataStore/constants';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
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 { 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
ModuleRegistry.registerModules([
@@ -81,15 +91,22 @@ 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,
mode: 'multiRow',
enableClickSelection: false,
checkboxes: true,
};
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
const defaultColumnDef = {
const defaultColumnDef: ColDef = {
flex: 1,
sortable: 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 columnDef: ColDef = {
colId: col.id,
@@ -192,6 +210,7 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
editable: true,
resizable: true,
headerComponent: ColumnHeader,
cellEditorPopup: false,
headerComponentParams: { onDelete: onDeleteColumn },
...extraProps,
cellDataType: dataStoreTypes.mapToAGCellType(col.type),
@@ -209,15 +228,61 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
}
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
if (col.type === 'string') {
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
if (col.type === 'date') {
columnDef.cellEditor = 'agDateCellEditor';
columnDef.cellEditorPopup = true;
}
return columnDef;
};
@@ -252,16 +317,20 @@ const onAddRowClick = async () => {
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'));
}
contentLoading.value = 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) {
toast.showError(error, i18n.baseText('dataStore.addRow.error'));
} finally {
emit('toggleSave', false);
contentLoading.value = false;
}
};
@@ -288,21 +357,67 @@ const initColumnDefinitions = () => {
];
};
const onCellValueChanged = async (params: CellValueChangedEvent) => {
const { data, api } = params;
const onCellValueChanged = async (params: CellValueChangedEvent<DataStoreRow>) => {
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 {
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) {
// 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'));
} finally {
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 () => {
try {
contentLoading.value = true;
@@ -316,11 +431,7 @@ const fetchDataStoreContent = async () => {
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'));
}
toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
} finally {
contentLoading.value = false;
if (gridApi.value) {
@@ -329,6 +440,14 @@ const fetchDataStoreContent = async () => {
}
};
onClickOutside(gridContainer, () => {
resetLastFocusedCell();
});
const resetLastFocusedCell = () => {
lastFocusedCell.value = null;
};
const initialize = async () => {
initColumnDefinitions();
await fetchDataStoreContent();
@@ -337,11 +456,25 @@ const initialize = async () => {
onMounted(async () => {
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>
<template>
<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
style="width: 100%"
:row-data="rowData"
@@ -355,13 +488,17 @@ onMounted(async () => {
:suppress-drag-leave-hides-columns="true"
:loading="contentLoading"
:row-selection="rowSelection"
:get-row-id="(params) => String(params.data.id)"
:single-click-edit="true"
:get-row-id="(params: GetRowIdParams) => String(params.data.id)"
:stop-editing-when-cells-lose-focus="true"
:undo-redo-cell-editing="true"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
@column-moved="onColumnMoved"
@cell-clicked="onCellClicked"
@cell-editing-started="onCellEditingStarted"
@cell-editing-stopped="onCellEditingStopped"
@column-header-clicked="resetLastFocusedCell"
@selection-changed="resetLastFocusedCell"
/>
<AddColumnPopover
:data-store="props.dataStore"
@@ -430,6 +567,15 @@ onMounted(async () => {
:global(.ag-header-cell-resize) {
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 {

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 NO_TABLE_YET_MESSAGE = 'SQLITE_ERROR: no such table:';
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';

View File

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

View File

@@ -12,7 +12,7 @@ import {
moveDataStoreColumnApi,
getDataStoreRowsApi,
insertDataStoreRowApi,
upsertDataStoreRowsApi,
updateDataStoreRowsApi,
} from '@/features/dataStore/dataStore.api';
import type {
DataStore,
@@ -20,14 +20,11 @@ import type {
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);
@@ -185,19 +182,30 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
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);
emptyRow[column.name] = null;
});
return await insertDataStoreRowApi(
const inserted = await insertDataStoreRowApi(
rootStore.restApiContext,
dataStore.id,
emptyRow,
dataStore.projectId,
);
return inserted[0];
};
const upsertRow = async (dataStoreId: string, projectId: string, row: DataStoreRow) => {
return await upsertDataStoreRowsApi(rootStore.restApiContext, dataStoreId, [row], projectId);
const updateRow = async (
dataStoreId: string,
projectId: string,
rowId: number,
rowData: DataStoreRow,
) => {
return await updateDataStoreRowsApi(
rootStore.restApiContext,
dataStoreId,
rowId,
rowData,
projectId,
);
};
return {
@@ -214,6 +222,6 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
moveDataStoreColumn,
fetchDataStoreContent,
insertEmptyRow,
upsertRow,
updateRow,
};
});

View File

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