feat(editor): Improve Data Table UX based on feedback (no-changelog) (#19312)

This commit is contained in:
Milorad FIlipović
2025-09-10 14:14:25 +02:00
committed by GitHub
parent 52d44c26db
commit 6e6a8f8be8
19 changed files with 250 additions and 102 deletions

View File

@@ -495,14 +495,14 @@ function confirmFolderDelete(folderName: string) {
function deleteFolderAndMoveContents(folderName: string, destinationName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderDeleteModal().should('be.visible');
getFolderDeleteModal().find('h1').first().contains(`Delete "${folderName}"`);
getFolderDeleteModal().find('h1').first().contains(`Delete '${folderName}'`);
getTransferContentRadioButton().should('be.visible').click();
getMoveToFolderDropdown().click();
getMoveToFolderInput().type(destinationName);
getMoveToFolderOption(destinationName).click();
getDeleteFolderModalConfirmButton().should('be.enabled').click();
cy.wait('@deleteFolder');
successToast().should('contain.text', `Data transferred to "${destinationName}"`);
successToast().should('contain.text', `Data transferred to '${destinationName}'`);
}
function moveFolder(folderName: string, destinationName: string) {

View File

@@ -111,7 +111,7 @@
"generic.upgradeToEnterprise": "Upgrade to Enterprise",
"generic.never": "Never",
"generic.list.clearSelection": "Clear selection",
"generic.list.selected": "{count} row selected: | {count} rows selected:",
"generic.list.selected": "{count} row selected | {count} rows selected",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@@ -971,17 +971,17 @@
"folders.actions.moveToFolder": "Move",
"folders.add": "Add folder",
"folders.add.here.message": "Create a new folder here",
"folders.add.to.parent.message": "Create folder in \"{parent}\"",
"folders.add.to.parent.message": "Create folder in '{parent}'",
"folders.add.overview.community.message": "Folders available in your personal space",
"folders.add.overview.withProjects.message": "Folders available in projects or your personal space",
"folders.add.success.title": "Folder created",
"folders.add.success.message": "Created new folder \"{folderName}\"<br><a href=\"{link}\">Open folder</a>",
"folders.add.success.message": "Created new folder '{folderName}'<br><a href=\"{link}\">Open folder</a>",
"folders.invalidName.empty.message": "Folder name cannot be empty",
"folders.invalidName.tooLong.message": "Folder name cannot be longer than {maxLength} characters",
"folders.invalidName.invalidCharacters.message": "Folder name cannot contain the following characters: {illegalChars}",
"folders.invalidName.starts.with.dot..message": "Folder name cannot start with a dot",
"folders.invalidName.only.dots.message": "Folder name cannot contain only dots",
"folders.delete.confirm.title": "Delete \"{folderName}\"",
"folders.delete.confirm.title": "Delete '{folderName}'",
"folders.delete.typeToConfirm": "delete {folderName}",
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
"folders.delete.success.message": "Folder deleted",
@@ -991,13 +991,13 @@
"folder.and.workflow.separator": "and",
"folders.delete.action": "Archive all workflows and delete subfolders",
"folders.delete.error.message": "Problem while deleting folder",
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
"folders.transfer.action": "Transfer workflows and subfolders to another folder inside \"{projectName}\"",
"folders.delete.confirmation.message": "Type 'delete {folderName}' to confirm",
"folders.transfer.confirm.message": "Data transferred to '{folderName}'",
"folders.transfer.action": "Transfer workflows and subfolders to another folder inside '{projectName}'",
"folders.transfer.action.noProject": "Transfer workflows and subfolders to another folder",
"folders.transfer.selectFolder": "Folder to transfer to",
"folders.transfer.select.placeholder": "Select folder",
"folders.rename.message": "Rename \"{folderName}\"",
"folders.rename.message": "Rename '{folderName}'",
"folders.rename.error.title": "Problem renaming folder",
"folders.rename.success.message": "Folder renamed",
"folders.rename.placeholder": "Enter new folder name",
@@ -2844,8 +2844,7 @@
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
"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.empty.description": "Use data tables to persist execution results, share data between workflows, and track metrics for evaluation.",
"dataStore.card.size": "{size}MB",
"dataStore.card.column.count": "{count} column | {count} columns",
"dataStore.card.row.count": "{count} record | {count} records",
@@ -2856,13 +2855,12 @@
"dataStore.search.placeholder": "Search",
"dataStore.error.fetching": "Error loading data tables",
"dataStore.add.title": "Create new 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.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",
@@ -2875,10 +2873,12 @@
"dataStore.addColumn.nameInput.placeholder": "Enter column name",
"dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type",
"dataStore.addColumn.error": "Error adding column",
"dataStore.addColumn.alreadyExistsError": "This column already exists",
"dataStore.addColumn.alreadyExistsDescription": "Column name already exisits, choose a different name",
"dataStore.moveColumn.error": "Error moving column",
"dataStore.deleteColumn.error": "Error deleting column",
"dataStore.deleteColumn.confirm.title": "Delete column",
"dataStore.deleteColumn.confirm.message": "Are you sure you want to delete the column \"{name}\"? This action cannot be undone.",
"dataStore.deleteColumn.confirm.message": "Are you sure you want to delete the column '{name}'? This action cannot be undone.",
"dataStore.addColumn.invalidName.error": "Invalid column name",
"dataStore.addColumn.invalidName.description": "Column names must begin with a letter and can only include letters, numbers, or underscores",
"dataStore.fetchContent.error": "Error fetching data store content",
@@ -2887,8 +2887,9 @@
"dataStore.updateRow.error": "Error updating row",
"dataStore.deleteRows.title": "Delete Rows",
"dataStore.deleteRows.confirmation": "Are you sure you want to delete {count} row? | Are you sure you want to delete {count} rows?",
"dataStore.deleteRows.success": "Rows deleted successfully",
"dataStore.deleteRows.error": "Error deleting rows",
"dataStore.error.tableNotInitialized": "Table not initialized",
"dataStore.noRows": "No rows",
"settings.ldap": "LDAP",
"settings.ldap.note": "LDAP allows users to authenticate with their centralized account. It's compatible with services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.",
"settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",

View File

@@ -45,15 +45,17 @@ const handleClearSelection = () => {
{{ getSelectedText() }}
</span>
<N8nButton
:label="i18n.baseText('generic.delete')"
type="tertiary"
data-test-id="delete-selected-button"
:label="i18n.baseText('generic.delete')"
:class="$style.button"
@click="handleDeleteSelected"
/>
<N8nButton
:label="getClearSelectionText()"
type="tertiary"
data-test-id="clear-selection-button"
:label="getClearSelectionText()"
:class="$style.button"
@click="handleClearSelection"
/>
</div>
@@ -73,9 +75,11 @@ const handleClearSelection = () => {
border-radius: var(--border-radius-base);
color: var(--execution-selector-text);
font-size: var(--font-size-2xs);
gap: var(--spacing-2xs);
}
button {
margin-left: var(--spacing-2xs);
}
.button {
display: flex;
align-items: center;
}
</style>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { DataStore, DataStoreColumnCreatePayload } from '@/features/dataStore/datastore.types';
import type {
AddColumnResponse,
DataStore,
DataStoreColumnCreatePayload,
} from '@/features/dataStore/datastore.types';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n';
@@ -82,9 +86,12 @@ const onToggleSave = (value: boolean) => {
}
};
const onAddColumn = async (column: DataStoreColumnCreatePayload) => {
const onAddColumn = async (column: DataStoreColumnCreatePayload): Promise<AddColumnResponse> => {
if (!dataStoreTableRef.value) {
return false;
return {
success: false,
errorMessage: i18n.baseText('dataStore.error.tableNotInitialized'),
};
}
return await dataStoreTableRef.value.addColumn(column);
};

View File

@@ -7,7 +7,6 @@ import { useInsightsStore } from '@/features/insights/insights.store';
import { useI18n } from '@n8n/i18n';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ProjectTypes } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import type { SortingAndPaginationUpdates } from '@/Interface';
import type { DataStoreResource } from '@/features/dataStore/types';
@@ -61,23 +60,6 @@ const currentProject = computed(() => {
return projectsStore.currentProject;
});
const projectName = computed(() => {
if (currentProject.value?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject.value?.name ?? '';
});
const emptyCalloutDescription = computed(() => {
return projectPages.isOverviewSubPage ? i18n.baseText('dataStore.empty.description') : '';
});
const emptyCalloutButtonText = computed(() => {
return i18n.baseText('dataStore.empty.button.label', {
interpolate: { projectName: projectName.value },
});
});
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const initialize = async () => {
@@ -160,8 +142,8 @@ watch(
<n8n-action-box
data-test-id="empty-shared-action-box"
:heading="i18n.baseText('dataStore.empty.label')"
:description="emptyCalloutDescription"
:button-text="emptyCalloutButtonText"
:description="i18n.baseText('dataStore.empty.description')"
:button-text="i18n.baseText('dataStore.add.button.label')"
button-type="secondary"
@click:button="onAddModalClick"
/>

View File

@@ -69,11 +69,12 @@ const redirectToDataStores = () => {
<template>
<Modal :name="props.modalName" :center="true" width="540px" :before-close="redirectToDataStores">
<template #header>
<h2>{{ i18n.baseText('dataStore.add.title') }}</h2>
<div :class="$style.header">
<h2>{{ i18n.baseText('dataStore.add.title') }}</h2>
</div>
</template>
<template #content>
<div :class="$style.content">
<p>{{ i18n.baseText('dataStore.add.description') }}</p>
<n8n-input-label
:label="i18n.baseText('dataStore.add.input.name.label')"
:required="true"
@@ -95,7 +96,7 @@ const redirectToDataStores = () => {
<div :class="$style.footer">
<n8n-button
:disabled="!dataStoreName"
:label="i18n.baseText('dataStore.add.button.label')"
:label="i18n.baseText('generic.create')"
data-test-id="confirm-add-data-store-button"
@click="onSubmit"
/>
@@ -111,10 +112,13 @@ const redirectToDataStores = () => {
</template>
<style module lang="scss">
.header {
margin-bottom: var(--spacing-xs);
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-l);
}
.footer {

View File

@@ -137,11 +137,12 @@ watch(
<style lang="scss" module>
.data-store-breadcrumbs {
display: flex;
align-items: end;
align-items: center;
}
.data-store-actions {
position: relative;
top: var(--spacing-5xs);
}
.separator {

View File

@@ -43,7 +43,7 @@ const dataStoreRoute = computed(() => {
/>
</template>
<template #header>
<div :class="$style['card-header']" @click.prevent>
<div :class="$style['card-header']">
<n8n-text tag="h2" bold data-test-id="data-store-card-name">
{{ props.dataStore.name }}
</n8n-text>

View File

@@ -43,7 +43,7 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
}));
describe('AddColumnButton', () => {
const addColumnHandler = vi.fn().mockResolvedValue(true);
const addColumnHandler = vi.fn().mockResolvedValue({ success: true });
const renderComponent = createComponentRenderer(AddColumnButton, {
props: {
params: {
@@ -228,7 +228,7 @@ describe('AddColumnButton', () => {
});
it('should close popover after successful submission', async () => {
const { getByPlaceholderText, getByTestId, queryByText } = renderComponent();
const { getByPlaceholderText, getByTestId, queryByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -240,13 +240,16 @@ describe('AddColumnButton', () => {
await fireEvent.click(submitButton);
await waitFor(() => {
expect(queryByText('Column name')).not.toBeInTheDocument();
expect(queryByTestId('add-column-popover-content')).not.toBeInTheDocument();
});
});
it('should not close popover if submission fails', async () => {
const { getByPlaceholderText, getByTestId } = renderComponent();
addColumnHandler.mockResolvedValueOnce(false);
addColumnHandler.mockResolvedValueOnce({
success: false,
error: 'Column name already exists',
});
const addButton = getByTestId('data-store-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -258,7 +261,7 @@ describe('AddColumnButton', () => {
await fireEvent.click(submitButton);
await waitFor(() => {
expect(getByTestId('data-store-add-column-submit-button')).toBeInTheDocument();
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import type {
AddColumnResponse,
DataStoreColumnCreatePayload,
DataStoreColumnType,
} from '@/features/dataStore/datastore.types';
@@ -10,10 +11,15 @@ 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';
type FormError = {
message?: string;
description?: string;
};
const props = defineProps<{
// the params key is needed so that we can pass this directly to ag-grid as column
params: {
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<boolean>;
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<AddColumnResponse>;
};
popoverId?: string;
useTextTrigger?: boolean;
@@ -30,7 +36,7 @@ const columnType = ref<DataStoreColumnType>('string');
const columnTypes: DataStoreColumnType[] = ['string', 'number', 'boolean', 'date'];
const error = ref<string | null>(null);
const error = ref<FormError | null>(null);
// Handling popover state manually to prevent it closing when interacting with dropdown
const popoverOpen = ref(false);
@@ -43,11 +49,26 @@ const onAddButtonClicked = async () => {
if (!columnName.value || !columnType.value || error.value) {
return;
}
const success = await props.params.onAddColumn({
const response = await props.params.onAddColumn({
name: columnName.value,
type: columnType.value,
});
if (!success) {
if (!response.success) {
let errorMessage = i18n.baseText('dataStore.addColumn.error');
let errorDescription = response.errorMessage;
// Provide custom error message for conflict (column already exists)
if (response.httpStatus === 409) {
errorMessage = i18n.baseText('dataStore.addColumn.alreadyExistsError', {
interpolate: { name: columnName.value },
});
errorDescription = i18n.baseText('dataStore.addColumn.alreadyExistsDescription');
}
error.value = {
message: errorMessage,
description: errorDescription,
};
return;
}
columnName.value = '';
@@ -74,7 +95,10 @@ const validateName = () => {
error.value = null;
}
if (columnName.value && !COLUMN_NAME_REGEX.test(columnName.value)) {
error.value = i18n.baseText('dataStore.addColumn.invalidName.error');
error.value = {
message: i18n.baseText('dataStore.addColumn.invalidName.error'),
description: i18n.baseText('dataStore.addColumn.invalidName.description'),
};
}
};
@@ -107,7 +131,10 @@ const onInput = debounce(validateName, { debounceTime: 100 });
</template>
</template>
<template #content>
<div class="add-ds-column-header-popover-content">
<div
class="add-ds-column-header-popover-content"
data-test-id="add-column-popover-content"
>
<div class="popover-body">
<N8nInputLabel
:label="i18n.baseText('dataStore.addColumn.nameInput.label')"
@@ -123,10 +150,14 @@ const onInput = debounce(validateName, { debounceTime: 100 });
@input="onInput"
/>
<div v-if="error" class="error-message">
<n8n-text size="small" color="danger" tag="span">
{{ error }}
<n8n-text v-if="error.message" size="small" color="danger" tag="span">
{{ error.message }}
</n8n-text>
<Tooltip :content="i18n.baseText('dataStore.addColumn.invalidName.description')">
<Tooltip
:content="error.description"
placement="top"
:disabled="!error.description"
>
<N8nIcon
icon="circle-help"
size="small"

View File

@@ -33,6 +33,7 @@ import { useDataStorePagination } from '@/features/dataStore/composables/useData
import { useDataStoreGridBase } from '@/features/dataStore/composables/useDataStoreGridBase';
import { useDataStoreSelection } from '@/features/dataStore/composables/useDataStoreSelection';
import { useDataStoreOperations } from '@/features/dataStore/composables/useDataStoreOperations';
import { useI18n } from '@n8n/i18n';
// Register only the modules we actually use
ModuleRegistry.registerModules([
@@ -66,6 +67,8 @@ const emit = defineEmits<{
const gridContainerRef = useTemplateRef<HTMLDivElement>('gridContainerRef');
const i18n = useI18n();
const dataStoreGridBase = useDataStoreGridBase({
gridContainerRef,
onDeleteColumn: onDeleteColumnFunction,
@@ -75,7 +78,18 @@ const dataStoreGridBase = useDataStoreGridBase({
const rowData = ref<DataStoreRow[]>([]);
const hasRecords = computed(() => rowData.value.length > 0);
const pagination = useDataStorePagination({ onChange: fetchDataStoreRowsFunction });
const {
currentPage,
pageSize,
totalItems,
pageSizeOptions,
ensureItemOnPage,
setTotalItems,
setCurrentPage,
setPageSize,
} = useDataStorePagination({
onChange: fetchDataStoreRowsFunction,
});
const selection = useDataStoreSelection({
gridApi: dataStoreGridBase.gridApi,
@@ -92,13 +106,13 @@ const dataStoreOperations = useDataStoreOperations({
addGridColumn: dataStoreGridBase.addColumn,
moveGridColumn: dataStoreGridBase.moveColumn,
gridApi: dataStoreGridBase.gridApi,
totalItems: pagination.totalItems,
setTotalItems: pagination.setTotalItems,
ensureItemOnPage: pagination.ensureItemOnPage,
totalItems,
setTotalItems,
ensureItemOnPage,
focusFirstEditableCell: dataStoreGridBase.focusFirstEditableCell,
toggleSave: emit.bind(null, 'toggleSave'),
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
currentPage,
pageSize,
currentSortBy: dataStoreGridBase.currentSortBy,
currentSortOrder: dataStoreGridBase.currentSortOrder,
handleClearSelection: selection.handleClearSelection,
@@ -128,8 +142,10 @@ const initialize = async (params: GridReadyEvent) => {
await dataStoreOperations.fetchDataStoreRows();
};
const customNoRowsOverlay = `<div class="no-rows-overlay ag-overlay-no-rows-center" data-test-id="data-store-no-rows-overlay">${i18n.baseText('dataStore.noRows')}</div>`;
watch([dataStoreGridBase.currentSortBy, dataStoreGridBase.currentSortOrder], async () => {
await pagination.setCurrentPage(1);
await setCurrentPage(1);
});
defineExpose({
@@ -159,6 +175,7 @@ defineExpose({
:stop-editing-when-cells-lose-focus="true"
:undo-redo-cell-editing="true"
:suppress-multi-sort="true"
:overlay-no-rows-template="customNoRowsOverlay"
@grid-ready="initialize"
@cell-value-changed="dataStoreOperations.onCellValueChanged"
@column-moved="dataStoreOperations.onColumnMoved"
@@ -173,15 +190,15 @@ defineExpose({
</div>
<div :class="$style.footer">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
data-test-id="data-store-content-pagination"
background
:total="pagination.totalItems"
:page-sizes="pagination.pageSizeOptions"
:total="totalItems"
:page-sizes="pageSizeOptions"
layout="total, prev, pager, next, sizes"
@update:current-page="pagination.setCurrentPage"
@size-change="pagination.setPageSize"
@update:current-page="setCurrentPage"
@size-change="setPageSize"
/>
</div>
<SelectedItemsInfo
@@ -263,7 +280,11 @@ defineExpose({
:global(.id-column) {
color: var(--color-text-light);
justify-content: center;
}
:global(.system-column),
:global(.system-cell) {
color: var(--color-text-light);
}
:global(.ag-header-cell[col-id='id']) {
@@ -273,6 +294,12 @@ defineExpose({
:global(.add-row-cell) {
border: none !important;
background-color: transparent !important;
padding: 0;
button {
position: relative;
left: calc(var(--spacing-4xs) * -1);
}
}
:global(.ag-header-cell[col-id='add-column']) {
@@ -298,7 +325,7 @@ defineExpose({
padding: 0;
textarea {
padding-top: var(--spacing-xs);
padding-top: var(--spacing-2xs);
&:where(:focus-within, :active) {
border: var(--grid-cell-editing-border);
@@ -341,6 +368,10 @@ defineExpose({
:global(.ag-row[row-id='__n8n_add_row__']) {
border-bottom: none;
}
:global(.ag-row-last) {
border-bottom: none;
}
}
.footer {

View File

@@ -75,6 +75,7 @@ defineExpose({
:editable="false"
:teleported="false"
:placeholder="''"
size="small"
@change="onChange"
@clear="onClear"
@keydown="onKeydown"

View File

@@ -12,6 +12,7 @@ import type {
SortDirection,
} from 'ag-grid-community';
import type {
AddColumnResponse,
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
@@ -37,6 +38,7 @@ import {
createStringValueSetter,
stringCellEditorParams,
dateValueFormatter,
numberValueFormatter,
} from '@/features/dataStore/utils/columnUtils';
export const useDataStoreGridBase = ({
@@ -48,7 +50,7 @@ export const useDataStoreGridBase = ({
gridContainerRef: Ref<HTMLElement | null>;
onDeleteColumn: (columnId: string) => void;
onAddRowClick: () => void;
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<boolean>;
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<AddColumnResponse>;
}) => {
const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]);
@@ -98,15 +100,21 @@ export const useDataStoreGridBase = ({
const focusFirstEditableCell = (rowId: number) => {
const rowNode = initializedGridApi.value.getRowNode(String(rowId));
if (rowNode?.rowIndex === null) return;
const rowIndex = rowNode!.rowIndex;
const firstEditableCol = colDefs.value[1];
if (!firstEditableCol?.colId) return;
const columnId = firstEditableCol.colId;
initializedGridApi.value.ensureIndexVisible(rowNode!.rowIndex);
initializedGridApi.value.setFocusedCell(rowNode!.rowIndex, firstEditableCol.colId);
initializedGridApi.value.startEditingCell({
rowIndex: rowNode!.rowIndex,
colKey: firstEditableCol.colId,
requestAnimationFrame(() => {
initializedGridApi.value.ensureIndexVisible(rowIndex);
requestAnimationFrame(() => {
initializedGridApi.value.setFocusedCell(rowIndex, columnId);
initializedGridApi.value.startEditingCell({
rowIndex,
colKey: columnId,
});
});
});
};
@@ -140,6 +148,8 @@ export const useDataStoreGridBase = ({
component: ElDatePickerCellEditor,
});
columnDef.valueFormatter = dateValueFormatter;
} else if (col.type === 'number') {
columnDef.valueFormatter = numberValueFormatter;
}
return {
@@ -171,6 +181,8 @@ export const useDataStoreGridBase = ({
headerComponentParams: {
allowMenuActions: false,
},
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'system-cell'),
headerClass: 'system-column',
};
return [
// Always add the ID column, it's not returned by the back-end but all data stores have it
@@ -184,13 +196,14 @@ export const useDataStoreGridBase = ({
},
{
editable: false,
sortable: false,
sortable: true,
suppressMovable: true,
headerComponent: null,
lockPosition: true,
minWidth: DATA_STORE_ID_COLUMN_WIDTH,
maxWidth: DATA_STORE_ID_COLUMN_WIDTH,
resizable: false,
headerClass: 'system-column',
cellClass: (params) =>
params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'id-column',
cellRendererSelector: (params: ICellRendererParams) => {

View File

@@ -66,7 +66,7 @@ describe('useDataStoreOperations', () => {
});
const { onAddColumn } = useDataStoreOperations(params);
const result = await onAddColumn({ name: 'test', type: 'string' });
expect(result).toBe(false);
expect(result.success).toBe(false);
});
it('should add column when column is added', async () => {
@@ -78,7 +78,7 @@ describe('useDataStoreOperations', () => {
const rowData = ref([{ id: 1 }]);
const { onAddColumn } = useDataStoreOperations({ ...params, rowData });
const result = await onAddColumn({ name: returnedColumn.name, type: returnedColumn.type });
expect(result).toBe(true);
expect(result.success).toBe(true);
expect(params.setGridData).toHaveBeenCalledWith({ rowData: [{ id: 1, test: null }] });
expect(params.addGridColumn).toHaveBeenCalledWith(returnedColumn);
});

View File

@@ -2,6 +2,7 @@ import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import type {
AddColumnResponse,
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
@@ -18,6 +19,7 @@ import type {
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { MODAL_CONFIRM } from '@/constants';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
import { useDataStoreTypes } from './useDataStoreTypes';
export type UseDataStoreOperationsParams = {
colDefs: Ref<ColDef[]>;
@@ -74,6 +76,7 @@ export const useDataStoreOperations = ({
const dataStoreStore = useDataStoreStore();
const contentLoading = ref(false);
const telemetry = useTelemetry();
const dataStoreTypes = useDataStoreTypes();
async function onDeleteColumn(columnId: string) {
const columnToDelete = colDefs.value.find((col) => col.colId === columnId);
@@ -117,7 +120,7 @@ export const useDataStoreOperations = ({
}
}
async function onAddColumn(column: DataStoreColumnCreatePayload) {
async function onAddColumn(column: DataStoreColumnCreatePayload): Promise<AddColumnResponse> {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(dataStoreId, projectId, column);
addGridColumn(newColumn);
@@ -130,10 +133,14 @@ export const useDataStoreOperations = ({
column_type: newColumn.type,
data_table_id: dataStoreId,
});
return true;
return { success: true, httpStatus: 200 };
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
return false;
const addColumnError = dataStoreTypes.getAddColumnError(error);
return {
success: false,
httpStatus: addColumnError.httpStatus,
errorMessage: addColumnError.message,
};
}
}
@@ -270,11 +277,6 @@ export const useDataStoreOperations = ({
await dataStoreStore.deleteRows(dataStoreId, projectId, idsToDelete);
await fetchDataStoreRows();
toast.showToast({
title: i18n.baseText('dataStore.deleteRows.success'),
message: '',
type: 'success',
});
telemetry.track('User deleted rows in data table', {
data_table_id: dataStoreId,
deleted_row_count: idsToDelete.length,
@@ -287,17 +289,28 @@ export const useDataStoreOperations = ({
};
const onCellKeyDown = async (params: CellKeyDownEvent<DataStoreRow>) => {
if (params.api.getEditingCells().length > 0) {
const event = params.event as KeyboardEvent;
const target = event.target as HTMLElement;
const isSelectionColumn = params.column.getColId() === 'ag-Grid-SelectionColumn';
const isEditing =
params.api.getEditingCells().length > 0 ||
(target instanceof HTMLInputElement && !isSelectionColumn);
if (isEditing) {
return;
}
const event = params.event as KeyboardEvent;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
event.preventDefault();
await handleCopyFocusedCell(params);
return;
}
if (event.key === 'Escape') {
handleClearSelection();
return;
}
if ((event.key !== 'Delete' && event.key !== 'Backspace') || selectedRowIds.value.size === 0) {
return;
}

View File

@@ -5,6 +5,8 @@ import type {
DataStoreValue,
} from '@/features/dataStore/datastore.types';
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
import { ResponseError } from '@n8n/rest-api-client';
import { useI18n } from '@n8n/i18n';
/* eslint-disable id-denylist */
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
@@ -17,6 +19,7 @@ const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
export const useDataStoreTypes = () => {
const getIconForType = (type: DataStoreColumnType) => COLUMN_TYPE_ICONS[type];
const i18n = useI18n();
/**
* Maps a DataStoreColumnType to an AGGridCellType.
@@ -57,10 +60,33 @@ export const useDataStoreTypes = () => {
}
};
const getAddColumnError = (error: unknown): { httpStatus: number; message: string } => {
const DEFAULT_HTTP_STATUS = 500;
const DEFAULT_MESSAGE = i18n.baseText('generic.unknownError');
if (error instanceof ResponseError) {
return {
httpStatus: error.httpStatusCode ?? 500,
message: error.message,
};
}
if (error instanceof Error) {
return {
httpStatus: DEFAULT_HTTP_STATUS,
message: error.message,
};
}
return {
httpStatus: DEFAULT_HTTP_STATUS,
message: DEFAULT_MESSAGE,
};
};
return {
getIconForType,
mapToAGCellType,
mapToDataStoreColumnType,
getDefaultValueForType,
getAddColumnError,
};
};

View File

@@ -8,10 +8,10 @@ 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_ID_COLUMN_WIDTH = 60;
export const DATA_STORE_HEADER_HEIGHT = 36;
export const DATA_STORE_ROW_HEIGHT = 43;
export const DATA_STORE_ROW_HEIGHT = 33;
export const ADD_ROW_ROW_ID = '__n8n_add_row__';
@@ -35,3 +35,7 @@ export const NULL_VALUE = 'Null';
export const EMPTY_VALUE = 'Empty';
export const DATA_STORE_MODULE_NAME = 'data-table';
export const NUMBER_WITH_SPACES_REGEX = /\B(?=(\d{3})+(?!\d))/g;
export const NUMBER_THOUSAND_SEPARATOR = ' ';
export const NUMBER_DECIMAL_SEPARATOR = '.';

View File

@@ -27,3 +27,9 @@ export type DataStoreColumnCreatePayload = Pick<DataStoreColumn, 'name' | 'type'
export type DataStoreValue = string | number | boolean | Date | null;
export type DataStoreRow = Record<string, DataStoreValue>;
export type AddColumnResponse = {
success: boolean;
httpStatus?: number;
errorMessage?: string;
};

View File

@@ -8,7 +8,14 @@ import type {
} from 'ag-grid-community';
import type { Ref } from 'vue';
import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types';
import { ADD_ROW_ROW_ID, EMPTY_VALUE, NULL_VALUE } from '@/features/dataStore/constants';
import {
ADD_ROW_ROW_ID,
EMPTY_VALUE,
NULL_VALUE,
NUMBER_DECIMAL_SEPARATOR,
NUMBER_THOUSAND_SEPARATOR,
NUMBER_WITH_SPACES_REGEX,
} from '@/features/dataStore/constants';
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
@@ -93,3 +100,17 @@ export const dateValueFormatter = (
if (value === null || value === undefined) return '';
return value.toISOString();
};
const numberWithSpaces = (num: number) => {
const parts = num.toString().split('.');
parts[0] = parts[0].replace(NUMBER_WITH_SPACES_REGEX, NUMBER_THOUSAND_SEPARATOR);
return parts.join(NUMBER_DECIMAL_SEPARATOR);
};
export const numberValueFormatter = (
params: ValueFormatterParams<DataStoreRow, number | null | undefined>,
): string => {
const value = params.value;
if (value === null || value === undefined) return '';
return numberWithSpaces(value);
};