feat(editor): Data table UI redesign (no-changelog) (#18814)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Svetoslav Dekov
2025-08-27 15:01:17 +03:00
committed by GitHub
parent ed090f0e66
commit f67b7f2ba2
19 changed files with 375 additions and 205 deletions

View File

@@ -199,6 +199,7 @@ defineExpose({ open, close });
}
.icon {
display: flex;
text-align: center;
margin-right: var(--spacing-2xs);

View File

@@ -520,6 +520,9 @@
--color-menu-background: var(--p-gray-740);
--color-menu-hover-background: var(--p-gray-670);
--color-menu-active-background: var(--p-gray-670);
/* Ag Grid */
--grid-row-selected-background: var(--p-color-secondary-720);
}
body[data-theme='dark'] {

View File

@@ -686,6 +686,9 @@
// Params
--color-icon-base: var(--color-text-light);
--color-icon-hover: var(--p-color-primary-320);
/* Ag Grid */
--grid-row-selected-background: var(--p-color-secondary-070);
}
:root {

View File

@@ -2834,7 +2834,7 @@
"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 Tables",
"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}\"",
@@ -3004,8 +3004,10 @@
"settings.mfa.updateConfiguration": "MFA configuration updated",
"settings.mfa.invalidAuthenticatorCode": "Invalid authenticator code",
"projects.header.overview.subtitle": "All the workflows, credentials and executions you have access to",
"projects.header.overview.subtitleWithDataTables": "All the workflows, credentials and data tables you have access to",
"projects.header.shared.title": "Shared with you",
"projects.header.personal.subtitle": "Workflows and credentials owned by you",
"projects.header.personal.subtitleWithDataTables": "Workflows, credentials and data tables owned by you",
"projects.header.shared.subtitle": "Workflows and credentials other users have shared with you",
"projects.header.create.workflow": "Create Workflow",
"projects.header.create.credential": "Create Credential",

View File

@@ -123,6 +123,7 @@ describe('ProjectHeader', () => {
});
it('Overview: should render the correct title and subtitle', async () => {
settingsStore.isDataStoreFeatureEnabled = false;
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
const { getByTestId, rerender } = renderComponent();
const overviewSubtitle = 'All the workflows, credentials and executions you have access to';
@@ -146,6 +147,7 @@ describe('ProjectHeader', () => {
});
it('Personal: should render the correct title and subtitle', async () => {
settingsStore.isDataStoreFeatureEnabled = false;
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
const { getByTestId, rerender } = renderComponent();

View File

@@ -241,9 +241,17 @@ const sectionDescription = computed(() => {
if (projectPages.isSharedSubPage) {
return i18n.baseText('projects.header.shared.subtitle');
} else if (projectPages.isOverviewSubPage) {
return i18n.baseText('projects.header.overview.subtitle');
return i18n.baseText(
settingsStore.isDataStoreFeatureEnabled
? 'projects.header.overview.subtitleWithDataTables'
: 'projects.header.overview.subtitle',
);
} else if (isPersonalProject.value) {
return i18n.baseText('projects.header.personal.subtitle');
return i18n.baseText(
settingsStore.isDataStoreFeatureEnabled
? 'projects.header.personal.subtitleWithDataTables'
: 'projects.header.personal.subtitle',
);
}
return null;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { DataStore } from '@/features/dataStore/datastore.types';
import type { DataStore, DataStoreColumnCreatePayload } from '@/features/dataStore/datastore.types';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n';
@@ -10,6 +10,7 @@ import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBread
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import DataStoreTable from './components/dataGrid/DataStoreTable.vue';
import { useDebounce } from '@/composables/useDebounce';
import AddColumnButton from './components/dataGrid/AddColumnButton.vue';
type Props = {
id: string;
@@ -28,6 +29,8 @@ const dataStoreStore = useDataStoreStore();
const loading = ref(false);
const saving = ref(false);
const dataStore = ref<DataStore | null>(null);
const dataStoreTableRef = ref<InstanceType<typeof DataStoreTable>>();
const { debounce } = useDebounce();
const showErrorAndGoBackToList = async (error: unknown) => {
@@ -79,6 +82,10 @@ const onToggleSave = (value: boolean) => {
}
};
const onAddColumn = (column: DataStoreColumnCreatePayload) => {
dataStoreTableRef.value?.addColumn(column);
};
onMounted(async () => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
await initialize();
@@ -104,9 +111,23 @@ onMounted(async () => {
<n8n-spinner />
<n8n-text>{{ i18n.baseText('generic.saving') }}...</n8n-text>
</div>
<div :class="$style.actions">
<n8n-button @click="dataStoreTableRef?.addRow">{{
i18n.baseText('dataStore.addRow.label')
}}</n8n-button>
<AddColumnButton
:use-text-trigger="true"
:popover-id="'ds-details-add-column-popover'"
:params="{ onAddColumn }"
/>
</div>
</div>
<div :class="$style.content">
<DataStoreTable :data-store="dataStore" @toggle-save="onToggleSave" />
<DataStoreTable
ref="dataStoreTableRef"
:data-store="dataStore"
@toggle-save="onToggleSave"
/>
</div>
</div>
</div>
@@ -118,15 +139,11 @@ onMounted(async () => {
flex-direction: column;
height: 100%;
width: 100%;
max-width: var(--content-container-width);
box-sizing: border-box;
align-content: start;
padding: var(--spacing-l) var(--spacing-2xl) 0;
}
.header-loading {
margin-bottom: var(--spacing-2xl);
div {
height: 2em;
}
@@ -136,7 +153,11 @@ onMounted(async () => {
display: flex;
gap: var(--spacing-l);
align-items: center;
margin-bottom: var(--spacing-xl);
}
.header,
.header-loading {
padding: var(--spacing-s);
}
.saving {
@@ -145,4 +166,10 @@ onMounted(async () => {
gap: var(--spacing-3xs);
margin-top: var(--spacing-5xs);
}
.actions {
display: flex;
gap: var(--spacing-3xs);
margin-left: auto;
}
</style>

View File

@@ -97,6 +97,7 @@ watch(
<n8n-breadcrumbs
:items="breadcrumbs"
:separator="BREADCRUMBS_SEPARATOR"
:highlight-last-item="false"
@item-selected="onItemClicked"
>
<template #prepend>

View File

@@ -1,5 +1,5 @@
import { createComponentRenderer } from '@/__tests__/render';
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
@@ -42,8 +42,15 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
}),
}));
describe('AddColumnPopover', () => {
const renderComponent = createComponentRenderer(AddColumnPopover);
describe('AddColumnButton', () => {
const addColumnHandler = vi.fn();
const renderComponent = createComponentRenderer(AddColumnButton, {
props: {
params: {
onAddColumn: addColumnHandler,
},
},
});
beforeEach(() => {
setActivePinia(createPinia());
@@ -66,8 +73,8 @@ describe('AddColumnPopover', () => {
});
});
it('should emit addColumn event with correct payload', async () => {
const { getByTestId, getByPlaceholderText, emitted } = renderComponent();
it('should call addColumn with correct payload', async () => {
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -79,15 +86,10 @@ describe('AddColumnPopover', () => {
expect(submitButton).not.toBeDisabled();
await fireEvent.click(submitButton);
expect(emitted().addColumn).toBeTruthy();
expect(emitted().addColumn[0]).toEqual([
{
column: {
name: 'newColumn',
type: 'string',
},
},
]);
expect(addColumnHandler).toHaveBeenCalledWith({
name: 'newColumn',
type: 'string',
});
});
it('should disable submit button when name is empty', async () => {
@@ -173,13 +175,13 @@ describe('AddColumnPopover', () => {
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
const nameInput = getByPlaceholderText('Enter column name');
expect(nameInput.maxLength).toBe(MAX_COLUMN_NAME_LENGTH);
expect(nameInput.getAttribute('maxlength')).toBe(MAX_COLUMN_NAME_LENGTH.toString());
});
it('should allow selecting different column types', async () => {
const { getByPlaceholderText, getByRole, getByText, getByTestId, emitted } = renderComponent();
const { getByPlaceholderText, getByRole, getByText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -198,15 +200,10 @@ describe('AddColumnPopover', () => {
const submitButton = getByTestId('data-store-add-column-submit-button');
await fireEvent.click(submitButton);
expect(emitted().addColumn).toBeTruthy();
expect(emitted().addColumn[0]).toEqual([
{
column: {
name: 'numberColumn',
type: 'number',
},
},
]);
expect(addColumnHandler).toHaveBeenCalledWith({
name: 'numberColumn',
type: 'number',
});
});
it('should reset form after successful submission', async () => {
@@ -215,7 +212,7 @@ describe('AddColumnPopover', () => {
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'testColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
@@ -225,8 +222,8 @@ describe('AddColumnPopover', () => {
await fireEvent.click(addButton);
await waitFor(() => {
const resetNameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
expect(resetNameInput.value).toBe('');
const resetNameInput = getByPlaceholderText('Enter column name');
expect((resetNameInput as HTMLInputElement).value).toBe('');
});
});
@@ -248,7 +245,7 @@ describe('AddColumnPopover', () => {
});
it('should allow submission with Enter key', async () => {
const { getByTestId, getByPlaceholderText, emitted } = renderComponent();
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -257,15 +254,10 @@ describe('AddColumnPopover', () => {
await fireEvent.update(nameInput, 'enterColumn');
await fireEvent.keyUp(nameInput, { key: 'Enter' });
expect(emitted().addColumn).toBeTruthy();
expect(emitted().addColumn[0]).toEqual([
{
column: {
name: 'enterColumn',
type: 'string',
},
},
]);
expect(addColumnHandler).toHaveBeenCalledWith({
name: 'enterColumn',
type: 'string',
});
});
it('should display all column type options', async () => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import type {
DataStoreColumnCreatePayload,
DataStoreColumnType,
@@ -10,12 +10,13 @@ import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/
import Tooltip from '@n8n/design-system/components/N8nTooltip/Tooltip.vue';
import { useDebounce } from '@/composables/useDebounce';
const emit = defineEmits<{
addColumn: [
value: {
column: DataStoreColumnCreatePayload;
},
];
const props = defineProps<{
// the params key is needed so that we can pass this directly to ag-grid as column
params: {
onAddColumn: (column: DataStoreColumnCreatePayload) => void;
};
popoverId?: string;
useTextTrigger?: boolean;
}>();
const i18n = useI18n();
@@ -35,16 +36,13 @@ const error = ref<string | null>(null);
const popoverOpen = ref(false);
const isSelectOpen = ref(false);
const popoverId = computed(() => props.popoverId ?? 'add-column-popover');
const onAddButtonClicked = () => {
if (!columnName.value || !columnType.value) {
return;
}
emit('addColumn', {
column: {
name: columnName.value,
type: columnType.value,
},
});
props.params.onAddColumn({ name: columnName.value, type: columnType.value });
columnName.value = '';
columnType.value = 'string';
popoverOpen.value = false;
@@ -78,25 +76,32 @@ const onInput = debounce(validateName, { debounceTime: 100 });
<template>
<N8nTooltip :disabled="popoverOpen" :content="i18n.baseText('dataStore.addColumn.label')">
<div :class="$style.wrapper">
<div class="add-column-header-component-wrapper">
<N8nPopoverReka
id="add-column-popover"
:id="popoverId"
:open="popoverOpen"
:popper-options="{ strategy: 'fixed' }"
:show-arrow="false"
@update:open="handlePopoverOpenChange"
>
<template #trigger>
<N8nIconButton
data-test-id="data-store-add-column-trigger-button"
text
icon="plus"
type="tertiary"
/>
<template v-if="props.useTextTrigger">
<N8nButton data-test-id="data-store-add-column-trigger-button" type="tertiary">
{{ i18n.baseText('dataStore.addColumn.label') }}
</N8nButton>
</template>
<template v-else>
<N8nIconButton
data-test-id="data-store-add-column-trigger-button"
text
icon="plus"
type="tertiary"
/>
</template>
</template>
<template #content>
<div :class="$style['popover-content']">
<div :class="$style['popover-body']">
<div class="add-ds-column-header-popover-content">
<div class="popover-body">
<N8nInputLabel
:label="i18n.baseText('dataStore.addColumn.nameInput.label')"
:required="true"
@@ -110,7 +115,7 @@ const onInput = debounce(validateName, { debounceTime: 100 });
@keyup.enter="onAddButtonClicked"
@input="onInput"
/>
<div v-if="error" :class="$style['error-message']">
<div v-if="error" class="error-message">
<n8n-text size="small" color="danger" tag="span">
{{ error }}
</n8n-text>
@@ -118,7 +123,7 @@ const onInput = debounce(validateName, { debounceTime: 100 });
<N8nIcon
icon="circle-help"
size="small"
:class="$style['error-tooltip']"
class="error-tooltip"
color="text-base"
data-test-id="add-column-error-help-icon"
/>
@@ -128,15 +133,15 @@ const onInput = debounce(validateName, { debounceTime: 100 });
<N8nInputLabel
:label="i18n.baseText('dataStore.addColumn.typeInput.label')"
:required="true"
:class="$style['type-label']"
class="type-label"
>
<N8nSelect
v-model="columnType"
append-to="#add-column-popover"
:append-to="`#${popoverId}`"
@visible-change="isSelectOpen = $event"
>
<N8nOption v-for="type in columnTypes" :key="type" :value="type">
<div :class="$style['option-content']">
<div class="add-column-option-content">
<N8nIcon :icon="getIconForType(type)" />
<N8nText>{{ type }}</N8nText>
</div>
@@ -161,49 +166,38 @@ const onInput = debounce(validateName, { debounceTime: 100 });
</N8nTooltip>
</template>
<style module lang="scss">
.wrapper {
display: flex;
align-items: center;
background: var(--color-background-base);
padding: var(--spacing-2xs);
border: var(--border-base);
border-left: none;
height: 38px;
}
.popover-content {
<style lang="scss">
.add-ds-column-header-popover-content {
display: flex;
flex-direction: column;
width: 300px;
}
.popover-header {
padding: var(--spacing-2xs);
border-bottom: var(--border-base);
}
.popover-header {
padding: var(--spacing-2xs);
border-bottom: var(--border-base);
}
.popover-body {
padding: var(--spacing-xs);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.popover-body {
padding: var(--spacing-xs);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.option-content {
.error-message {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
color: var(--color-text-danger);
}
.error-tooltip {
cursor: pointer;
}
}
.add-column-option-content {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.error-message {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
color: var(--color-text-danger);
}
.error-tooltip {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
const i18n = useI18n();
const props = defineProps<{
params: {
onClick: () => void;
};
}>();
</script>
<template>
<N8nTooltip :content="i18n.baseText('dataStore.addRow.label')">
<N8nIconButton text type="tertiary" icon="plus" @click="props.params.onClick" />
</N8nTooltip>
</template>

View File

@@ -1,6 +1,5 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreTable from '@/features/dataStore/components/dataGrid/DataStoreTable.vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import type { DataStore } from '@/features/dataStore/datastore.types';
@@ -44,6 +43,7 @@ vi.mock('ag-grid-community', () => ({
ClientSideRowModelApiModule: {},
ValidationModule: {},
UndoRedoEditModule: {},
CellStyleModule: {},
}));
// Mock the n8n theme
@@ -137,47 +137,16 @@ describe('DataStoreTable', () => {
});
describe('Component Initialization', () => {
it('should render the component with AG Grid and AddColumnPopover', () => {
it('should render the component with AG Grid', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('ag-grid-vue')).toBeInTheDocument();
expect(getByTestId('add-column-popover')).toBeInTheDocument();
});
it('should render pagination controls', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-content-pagination')).toBeInTheDocument();
});
it('should render add row button', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-add-row-button')).toBeInTheDocument();
});
});
describe('Add Column Functionality', () => {
it('should handle add column event from AddColumnPopover', async () => {
const { getByTestId } = renderComponent();
const addColumnPopover = getByTestId('add-column-popover');
const addButton = addColumnPopover.querySelector(
'[data-test-id="data-store-add-column-button"]',
);
expect(addButton).toBeInTheDocument();
await fireEvent.click(addButton!);
await waitFor(() => {
expect(dataStoreStore.addDataStoreColumn).toHaveBeenCalledWith(
mockDataStore.id,
mockDataStore.projectId,
{ name: 'newColumn', type: 'string' },
);
});
});
});
describe('Empty Data Store', () => {

View File

@@ -38,18 +38,28 @@ import {
ClientSideRowModelApiModule,
ValidationModule,
UndoRedoEditModule,
CellStyleModule,
} from 'ag-grid-community';
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue';
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 {
DEFAULT_ID_COLUMN_NAME,
EMPTY_VALUE,
NULL_VALUE,
DATA_STORE_ID_COLUMN_WIDTH,
DATA_STORE_HEADER_HEIGHT,
DATA_STORE_ROW_HEIGHT,
ADD_ROW_ROW_ID,
} from '@/features/dataStore/constants';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue';
import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.vue';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import { onClickOutside } from '@vueuse/core';
@@ -68,6 +78,7 @@ ModuleRegistry.registerModules([
DateEditorModule,
ClientSideRowModelApiModule,
UndoRedoEditModule,
CellStyleModule,
]);
type Props = {
@@ -83,7 +94,7 @@ const emit = defineEmits<{
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const { getDefaultValueForType, mapToAGCellType } = useDataStoreTypes();
const { mapToAGCellType } = useDataStoreTypes();
const dataStoreStore = useDataStoreStore();
@@ -94,7 +105,8 @@ const rowData = ref<DataStoreRow[]>([]);
const rowSelection: RowSelectionOptions | 'single' | 'multiple' = {
mode: 'multiRow',
enableClickSelection: false,
checkboxes: true,
checkboxes: (params) => params.data?.id !== ADD_ROW_ROW_ID,
isRowSelectable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
};
const contentLoading = ref(false);
@@ -106,13 +118,6 @@ const isTextEditorOpen = ref(false);
const gridContainer = useTemplateRef('gridContainer');
// Shared config for all columns
const defaultColumnDef: ColDef = {
flex: 1,
sortable: false,
filter: false,
};
// Pagination
const pageSizeOptions = [10, 20, 50];
const currentPage = ref(1);
@@ -132,7 +137,12 @@ const onGridReady = (params: GridReadyEvent) => {
const refreshGridData = () => {
if (gridApi.value) {
gridApi.value.setGridOption('columnDefs', colDefs.value);
gridApi.value.setGridOption('rowData', rowData.value);
gridApi.value.setGridOption('rowData', [
...rowData.value,
{
id: ADD_ROW_ROW_ID,
},
]);
}
};
@@ -189,7 +199,7 @@ const onDeleteColumn = async (columnId: string) => {
}
};
const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) => {
const onAddColumn = async (column: DataStoreColumnCreatePayload) => {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(
props.dataStore.id,
@@ -199,9 +209,13 @@ const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload })
if (!newColumn) {
throw new Error(i18n.baseText('generic.unknownError'));
}
colDefs.value = [...colDefs.value, createColumnDef(newColumn)];
colDefs.value = [
...colDefs.value.slice(0, -1),
createColumnDef(newColumn),
...colDefs.value.slice(-1),
];
rowData.value = rowData.value.map((row) => {
return { ...row, [newColumn.name]: getDefaultValueForType(newColumn.type) };
return { ...row, [newColumn.name]: null };
});
refreshGridData();
} catch (error) {
@@ -214,13 +228,24 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
colId: col.id,
field: col.name,
headerName: col.name,
editable: true,
sortable: false,
flex: 1,
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
resizable: true,
lockPinned: true,
headerComponent: ColumnHeader,
cellEditorPopup: false,
headerComponentParams: { onDelete: onDeleteColumn },
cellDataType: mapToAGCellType(col.type),
cellClass: (params) => {
if (params.data?.id === ADD_ROW_ROW_ID) {
return 'add-row-cell';
}
if (params.column.getUserProvidedColDef()?.cellDataType === 'boolean') {
return 'boolean-cell';
}
return '';
},
valueGetter: (params: ValueGetterParams<DataStoreRow>) => {
// If the value is null, return null to show empty cell
if (params.data?.[col.name] === null || params.data?.[col.name] === undefined) {
@@ -236,6 +261,9 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
return params.data?.[col.name];
},
cellRendererSelector: (params: ICellRendererParams) => {
if (params.data?.id === ADD_ROW_ROW_ID || col.id === 'add-column') {
return {};
}
let rowValue = params.data?.[col.name];
// When adding new column, rowValue is undefined (same below, in string cell editor)
if (rowValue === undefined) {
@@ -289,7 +317,7 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
// Setup date editor
if (col.type === 'date') {
columnDef.cellEditor = 'agDateCellEditor';
columnDef.cellEditorPopup = true;
columnDef.cellEditorPopup = false;
}
return {
...columnDef,
@@ -324,8 +352,8 @@ const onColumnMoved = async (moveEvent: ColumnMovedEvent) => {
const onAddRowClick = async () => {
try {
// Go to last page if we are not there already
if (currentPage.value * pageSize.value < totalItems.value) {
await setCurrentPage(Math.ceil(totalItems.value / pageSize.value));
if (currentPage.value * pageSize.value < totalItems.value + 1) {
await setCurrentPage(Math.ceil((totalItems.value + 1) / pageSize.value));
}
contentLoading.value = true;
emit('toggleSave', true);
@@ -335,7 +363,9 @@ const onAddRowClick = async () => {
props.dataStore.columns.forEach((col) => {
newRow[col.name] = null;
});
rows.value.push(newRow);
rowData.value.push(newRow);
totalItems.value += 1;
refreshGridData();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addRow.error'));
} finally {
@@ -360,10 +390,42 @@ const initColumnDefinitions = () => {
suppressMovable: true,
headerComponent: null,
lockPosition: true,
minWidth: DATA_STORE_ID_COLUMN_WIDTH,
maxWidth: DATA_STORE_ID_COLUMN_WIDTH,
resizable: false,
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'id-column'),
cellRendererSelector: (params: ICellRendererParams) => {
if (params.value === ADD_ROW_ROW_ID) {
return {
component: AddRowButton,
params: {
onClick: onAddRowClick,
},
};
}
return undefined;
},
},
),
// Append other columns
...orderBy(props.dataStore.columns, 'index').map((col) => createColumnDef(col)),
createColumnDef(
{
index: props.dataStore.columns.length + 1,
id: 'add-column',
name: 'Add Column',
type: 'string',
},
{
editable: false,
suppressMovable: true,
lockPinned: true,
lockPosition: 'right',
resizable: false,
headerComponent: AddColumnButton,
headerComponentParams: { onAddColumn },
},
),
];
};
@@ -404,8 +466,11 @@ const onCellClicked = (params: CellClickedEvent<DataStoreRow>) => {
const clickedCellColumn = params.column.getColId();
const clickedCellRow = params.rowIndex;
// Skip if rowIndex is null
if (clickedCellRow === null) return;
if (
clickedCellRow === null ||
params.api.isEditing({ rowIndex: clickedCellRow, column: params.column, rowPinned: null })
)
return;
// Check if this is the same cell that was focused before this click
const wasAlreadyFocused =
@@ -437,10 +502,9 @@ const fetchDataStoreContent = async () => {
currentPage.value,
pageSize.value,
);
rows.value = fetchedRows.data;
rowData.value = fetchedRows.data;
totalItems.value = fetchedRows.count;
rowData.value = rows.value;
refreshGridData();
handleClearSelection();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
@@ -544,6 +608,11 @@ const handleClearSelection = () => {
gridApi.value.deselectAll();
}
};
defineExpose({
addRow: onAddRowClick,
addColumn: onAddColumn,
});
</script>
<template>
@@ -551,12 +620,9 @@ const handleClearSelection = () => {
<div ref="gridContainer" :class="$style['grid-container']" data-test-id="data-store-grid">
<AgGridVue
style="width: 100%"
:row-data="rowData"
:column-defs="colDefs"
:default-col-def="defaultColumnDef"
:dom-layout="'autoHeight'"
:row-height="36"
:header-height="36"
:row-height="DATA_STORE_ROW_HEIGHT"
:header-height="DATA_STORE_HEADER_HEIGHT"
:animate-rows="false"
:theme="n8nTheme"
:suppress-drag-leave-hides-columns="true"
@@ -574,22 +640,8 @@ const handleClearSelection = () => {
@column-header-clicked="resetLastFocusedCell"
@selection-changed="onSelectionChanged"
/>
<AddColumnPopover
:data-store="props.dataStore"
:class="$style['add-column-popover']"
@add-column="onAddColumn"
/>
</div>
<div :class="$style.footer">
<n8n-tooltip :content="i18n.baseText('dataStore.addRow.label')">
<n8n-icon-button
data-test-id="data-store-add-row-button"
icon="plus"
class="mb-xl"
type="secondary"
@click="onAddRowClick"
/>
</n8n-tooltip>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@@ -615,7 +667,6 @@ const handleClearSelection = () => {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
width: calc(100% - var(--spacing-m) * 2);
align-items: center;
}
@@ -626,22 +677,35 @@ const handleClearSelection = () => {
// AG Grid style overrides
--ag-foreground-color: var(--color-text-base);
--ag-accent-color: var(--color-primary);
--ag-cell-text-color: var(--color-text-dark);
--ag-accent-color: var(--p-color-secondary-470);
--ag-row-hover-color: var(--color-background-light-base);
--ag-background-color: var(--color-background-xlight);
--ag-border-color: var(--border-color-base);
--ag-border-radius: var(--border-radius-base);
--ag-wrapper-border-radius: 0;
--ag-font-family: var(--font-family);
--ag-font-size: var(--font-size-xs);
--ag-font-color: var(--color-text-base);
--ag-row-height: calc(var(--ag-grid-size) * 0.8 + 32px);
--ag-header-background-color: var(--color-background-light-base);
--ag-header-font-size: var(--font-size-xs);
--ag-header-font-weight: var(--font-weight-bold);
--ag-header-font-weight: var(--font-weight-medium);
--ag-header-foreground-color: var(--color-text-dark);
--ag-cell-horizontal-padding: var(--spacing-2xs);
--ag-header-column-resize-handle-color: var(--color-foreground-base);
--ag-header-column-resize-handle-height: 100%;
--ag-header-height: calc(var(--ag-grid-size) * 0.8 + 32px);
--ag-header-column-border-height: 100%;
--ag-range-selection-border-color: var(--p-color-secondary-470);
--ag-input-padding-start: var(--spacing-2xs);
--ag-input-background-color: var(--color-text-xlight);
--ag-focus-shadow: none;
--cell-editing-border: 2px solid var(--color-secondary);
:global(.ag-cell) {
display: flex;
align-items: center;
}
:global(.ag-header-cell-resize) {
width: var(--spacing-xs);
@@ -649,26 +713,101 @@ const handleClearSelection = () => {
right: -7px;
}
// Don't show borders for the checkbox cells
:global(.ag-cell[col-id='ag-Grid-SelectionColumn']) {
border: none;
padding-left: var(--spacing-l);
}
:global(.ag-header-cell[col-id='ag-Grid-SelectionColumn']) {
padding-left: var(--spacing-l);
&:after {
display: none;
}
}
:global(.ag-cell[col-id='ag-Grid-SelectionColumn'].ag-cell-focus) {
outline: none;
}
}
.add-column-popover {
display: flex;
position: absolute;
right: -47px;
:global(.ag-root-wrapper) {
border-left: none;
}
:global(.id-column) {
color: var(--color-text-light);
justify-content: center;
}
:global(.ag-header-cell[col-id='id']) {
text-align: center;
}
:global(.add-row-cell) {
border: none !important;
background-color: transparent !important;
}
:global(.ag-header-cell[col-id='add-column']) {
&:after {
display: none;
}
}
:global(.ag-cell-value[col-id='add-column']),
:global(.ag-cell-value[col-id='id']),
:global(.ag-cell[col-id='ag-Grid-SelectionColumn']) {
border: none;
background-color: transparent;
}
:global(.ag-cell-value[col-id='id']) {
border-right: 1px solid var(--ag-border-color);
}
:global(.ag-large-text-input) {
position: fixed;
padding: 0;
textarea {
padding-top: var(--spacing-xs);
&:where(:focus-within, :active) {
border: var(--cell-editing-border);
}
}
}
:global(.ag-center-cols-viewport) {
min-height: auto;
}
:global(.ag-checkbox-input-wrapper) {
&:where(:focus-within, :active) {
box-shadow: none;
}
}
:global(.ag-cell-inline-editing) {
box-shadow: none;
&:global(.boolean-cell) {
border: var(--cell-editing-border) !important;
&:global(.ag-cell-focus) {
background-color: var(--grid-cell-active-background);
}
}
}
:global(.ag-cell-focus) {
background-color: var(--grid-row-selected-background);
}
}
.footer {
display: flex;
width: 100%;
justify-content: space-between;
justify-content: flex-end;
margin-bottom: var(--spacing-l);
padding-right: var(--spacing-xl);

View File

@@ -13,6 +13,6 @@ const props = defineProps<{
<style lang="scss">
.n8n-empty-value {
font-style: italic;
color: var(--color-text-lighter);
color: var(--color-text-base);
}
</style>

View File

@@ -6,4 +6,8 @@ export const n8nTheme = themeQuartz.withPart(iconSetAlpine).withParams({
rowVerticalPaddingScale: 0.8,
sidePanelBorder: true,
wrapperBorder: true,
headerColumnBorder: { color: 'var(--color-foreground-base)' },
headerColumnBorderHeight: '100%',
checkboxUncheckedBackgroundColor: 'var(--color-background-light-base)',
checkboxCheckedBackgroundColor: 'var(--color-primary)',
});

View File

@@ -6,6 +6,13 @@ export const DATA_STORE_STORE = 'dataStoreStore';
export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
export const DATA_STORE_ID_COLUMN_WIDTH = 46;
export const DATA_STORE_HEADER_HEIGHT = 36;
export const DATA_STORE_ROW_HEIGHT = 43;
export const ADD_ROW_ROW_ID = '__n8n_add_row__';
export const DATA_STORE_CARD_ACTIONS = {
RENAME: 'rename',
DELETE: 'delete',

View File

@@ -151,7 +151,7 @@ export const insertDataStoreRowApi = async (
row: DataStoreRow,
projectId: string,
) => {
return await makeRestApiRequest<number[]>(
return await makeRestApiRequest<Array<{ id: number }>>(
context,
'POST',
`/projects/${projectId}/data-stores/${dataStoreId}/insert`,

View File

@@ -191,7 +191,7 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
emptyRow,
dataStore.projectId,
);
return inserted[0];
return inserted[0].id;
};
const updateRow = async (

View File

@@ -141,7 +141,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isFoldersFeatureEnabled = computed(() => folders.value.enabled);
const isDataStoreFeatureEnabled = computed(() => isModuleActive('data-store'));
const isDataStoreFeatureEnabled = computed(() => isModuleActive('data-table'));
const areTagsEnabled = computed(() =>
settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true,