mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Data store UI/UX improvements (no-changelog) (#18587)
Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io>
This commit is contained in:
committed by
GitHub
parent
c8dc7d9ab6
commit
802157a329
@@ -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",
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,7 +73,7 @@ const validateName = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onInput = debounce(validateName, { debounceTime: 300 });
|
||||
const onInput = debounce(validateName, { debounceTime: 100 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user