feat(editor): Address data tables UI feedback (no-changelog) (#18566)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Svetoslav Dekov
2025-08-25 11:25:28 +02:00
committed by GitHub
parent 01ff2bacc5
commit fb97ec876c
10 changed files with 151 additions and 97 deletions

View File

@@ -2837,7 +2837,7 @@
"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.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",
@@ -2847,9 +2847,9 @@
"dataStore.sort.nameDesc": "Sort by name (Z-A)",
"dataStore.search.placeholder": "Search",
"dataStore.error.fetching": "Error loading data tables",
"dataStore.add.title": "Create data table",
"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.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",

View File

@@ -44,10 +44,27 @@ const projectTabsSpy = vi.fn().mockReturnValue({
render: vi.fn(),
});
const ProjectCreateResourceStub = {
props: {
actions: Array,
},
template: `
<div>
<div data-test-id="add-resource"><button role="button"></button></div>
<button data-test-id="add-resource-workflow" @click="$emit('action', 'workflow')">Workflow</button>
<button data-test-id="action-credential" @click="$emit('action', 'credential')">Credentials</button>
<div data-test-id="add-resource-actions" >
<button v-for="action in $props.actions" :key="action.value"></button>
</div>
</div>
`,
};
const renderComponent = createComponentRenderer(ProjectHeader, {
global: {
stubs: {
ProjectTabs: projectTabsSpy,
ProjectCreateResource: ProjectCreateResourceStub,
},
},
});
@@ -69,6 +86,7 @@ describe('ProjectHeader', () => {
projectsStore.teamProjectsLimit = -1;
settingsStore.settings.folders = { enabled: false };
settingsStore.isDataStoreFeatureEnabled = true;
// Setup default moduleTabs structure
uiStore.moduleTabs = {
@@ -436,4 +454,21 @@ describe('ProjectHeader', () => {
);
});
});
describe('ProjectCreateResource', () => {
it('should render menu items', () => {
const { getByTestId } = renderComponent();
const actionsContainer = getByTestId('add-resource-actions');
expect(actionsContainer).toBeInTheDocument();
expect(actionsContainer.children).toHaveLength(2);
});
it('should not render datastore menu item if data store feature is disabled', () => {
settingsStore.isDataStoreFeatureEnabled = false;
const { getByTestId } = renderComponent();
const actionsContainer = getByTestId('add-resource-actions');
expect(actionsContainer).toBeInTheDocument();
expect(actionsContainer.children).toHaveLength(1);
});
});
});

View File

@@ -20,12 +20,7 @@ import { type IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { IUser } from 'n8n-workflow';
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useUIStore } from '@/stores/ui.store';
export type CustomAction = {
id: string;
label: string;
disabled?: boolean;
};
import { PROJECT_DATA_STORES } from '@/features/dataStore/constants';
const route = useRoute();
const router = useRouter();
@@ -37,17 +32,8 @@ const uiStore = useUIStore();
const projectPages = useProjectPages();
type Props = {
customActions?: CustomAction[];
};
const props = withDefaults(defineProps<Props>(), {
customActions: () => [],
});
const emit = defineEmits<{
createFolder: [];
customActionSelected: [actionId: string, projectId: string];
}>();
const headerIcon = computed((): IconOrEmoji => {
@@ -122,6 +108,7 @@ const ACTION_TYPES = {
WORKFLOW: 'workflow',
CREDENTIAL: 'credential',
FOLDER: 'folder',
DATA_STORE: 'dataStore',
} as const;
type ActionTypes = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
@@ -155,16 +142,17 @@ const menu = computed(() => {
});
}
// Append custom actions
if (props.customActions?.length) {
props.customActions.forEach((customAction) => {
items.push({
value: customAction.id,
label: customAction.label,
disabled: customAction.disabled ?? false,
});
if (settingsStore.isDataStoreFeatureEnabled) {
// TODO: this should probably be moved to the module descriptor as a setting
items.push({
value: ACTION_TYPES.DATA_STORE,
label: i18n.baseText('dataStore.add.button.label'),
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes)?.dataStore?.create,
});
}
return items;
});
@@ -196,6 +184,12 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
[ACTION_TYPES.FOLDER]: () => {
emit('createFolder');
},
[ACTION_TYPES.DATA_STORE]: (projectId: string) => {
void router.push({
name: PROJECT_DATA_STORES,
params: { projectId, new: 'new' },
});
},
} as const;
const pageType = computed(() => {
@@ -267,12 +261,6 @@ const onSelect = (action: string) => {
return;
}
// Check if this is a custom action
if (!executableAction) {
emit('customActionSelected', action, homeProject.value.id);
return;
}
executableAction(homeProject.value.id);
};
</script>

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import ProjectHeader, { type CustomAction } from '@/components/Projects/ProjectHeader.vue';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useProjectPages } from '@/composables/useProjectPages';
import { useInsightsStore } from '@/features/insights/insights.store';
import { useI18n } from '@n8n/i18n';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
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';
@@ -17,6 +16,7 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
import {
ADD_DATA_STORE_MODAL_KEY,
DEFAULT_DATA_STORE_PAGE_SIZE,
PROJECT_DATA_STORES,
} from '@/features/dataStore/constants';
import { useDebounce } from '@/composables/useDebounce';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
@@ -26,6 +26,7 @@ import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const projectPages = useProjectPages();
const { callDebounced } = useDebounce();
const documentTitle = useDocumentTitle();
@@ -35,20 +36,13 @@ const dataStoreStore = useDataStoreStore();
const insightsStore = useInsightsStore();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const uiStore = useUIStore();
const loading = ref(true);
const currentPage = ref(1);
const pageSize = ref(DEFAULT_DATA_STORE_PAGE_SIZE);
const customProjectActions = computed<CustomAction[]>(() => [
{
id: 'add-data-store',
label: i18n.baseText('dataStore.add.button.label'),
disabled: loading.value || projectPages.isOverviewSubPage,
},
]);
const dataStoreResources = computed<DataStoreResource[]>(() =>
dataStoreStore.dataStores.map((ds) => {
return {
@@ -58,6 +52,10 @@ const dataStoreResources = computed<DataStoreResource[]>(() =>
}),
);
const projectId = computed(() => {
return Array.isArray(route.params.projectId) ? route.params.projectId[0] : route.params.projectId;
});
const totalCount = computed(() => dataStoreStore.totalCount);
const currentProject = computed(() => projectsStore.currentProject);
@@ -86,11 +84,8 @@ const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly
const initialize = async () => {
loading.value = true;
const projectId = Array.isArray(route.params.projectId)
? route.params.projectId[0]
: route.params.projectId;
try {
await dataStoreStore.fetchDataStores(projectId, currentPage.value, pageSize.value);
await dataStoreStore.fetchDataStores(projectId.value, currentPage.value, pageSize.value);
} catch (error) {
toast.showError(error, 'Error loading data stores');
} finally {
@@ -111,18 +106,27 @@ const onPaginationUpdate = async (payload: SortingAndPaginationUpdates) => {
};
const onAddModalClick = () => {
useUIStore().openModal(ADD_DATA_STORE_MODAL_KEY);
};
const onProjectHeaderAction = (action: string) => {
if (action === 'add-data-store') {
useUIStore().openModal(ADD_DATA_STORE_MODAL_KEY);
}
void router.push({
name: PROJECT_DATA_STORES,
params: { projectId: projectId.value, new: 'new' },
});
};
onMounted(() => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
});
watch(
() => route.params.new,
() => {
if (route.params.new === 'new') {
uiStore.openModal(ADD_DATA_STORE_MODAL_KEY);
} else {
uiStore.closeModal(ADD_DATA_STORE_MODAL_KEY);
}
},
{ immediate: true },
);
</script>
<template>
<ResourcesListLayout
@@ -144,10 +148,7 @@ onMounted(() => {
@update:pagination-and-sort="onPaginationUpdate"
>
<template #header>
<ProjectHeader
:custom-actions="customProjectActions"
@custom-action-selected="onProjectHeaderAction"
>
<ProjectHeader>
<InsightsSummary
v-if="projectPages.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.weeklySummary.isLoading"

View File

@@ -4,7 +4,8 @@ import { onMounted, ref } from 'vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useUIStore } from '@/stores/ui.store';
import { useToast } from '@/composables/useToast';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { DATA_STORE_DETAILS, PROJECT_DATA_STORES } from '@/features/dataStore/constants';
type Props = {
modalName: string;
@@ -16,6 +17,7 @@ const dataStoreStore = useDataStoreStore();
const uiStore = useUIStore();
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const toast = useToast();
@@ -31,18 +33,35 @@ onMounted(() => {
const onSubmit = async () => {
try {
await dataStoreStore.createDataStore(dataStoreName.value, route.params.projectId as string);
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.add.error'));
} finally {
const newDataStore = await dataStoreStore.createDataStore(
dataStoreName.value,
route.params.projectId as string,
);
void router.push({
name: DATA_STORE_DETAILS,
params: {
id: newDataStore.id,
},
});
dataStoreName.value = '';
uiStore.closeModal(props.modalName);
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.add.error'));
}
};
const onCancel = () => {
uiStore.closeModal(props.modalName);
redirectToDataStores();
};
const redirectToDataStores = () => {
void router.replace({ name: PROJECT_DATA_STORES });
};
</script>
<template>
<Modal :name="props.modalName" :center="true" width="540px">
<Modal :name="props.modalName" :center="true" width="540px" :before-close="redirectToDataStores">
<template #header>
<h2>{{ i18n.baseText('dataStore.add.title') }}</h2>
</template>
@@ -66,7 +85,7 @@ const onSubmit = async () => {
</n8n-input-label>
</div>
</template>
<template #footer="{ close }">
<template #footer>
<div :class="$style.footer">
<n8n-button
:disabled="!dataStoreName"
@@ -78,7 +97,7 @@ const onSubmit = async () => {
type="secondary"
:label="i18n.baseText('generic.cancel')"
data-test-id="cancel-add-data-store-button"
@click="close"
@click="onCancel"
/>
</div>
</template>

View File

@@ -96,7 +96,7 @@ describe('DataStoreBreadcrumbs', () => {
});
expect(getByText('Data Stores')).toBeInTheDocument();
const separators = getAllByText('');
const separators = getAllByText('/');
expect(separators.length).toBeGreaterThan(0);
});
@@ -224,7 +224,7 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
const separators = getAllByText('');
const separators = getAllByText('/');
expect(separators.length).toBeGreaterThan(0);
});
});

View File

@@ -9,7 +9,7 @@ import { PROJECT_DATA_STORES } from '@/features/dataStore/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useToast } from '@/composables/useToast';
const BREADCRUMBS_SEPARATOR = '';
const BREADCRUMBS_SEPARATOR = '/';
type Props = {
dataStore: DataStore;

View File

@@ -82,7 +82,7 @@ const emit = defineEmits<{
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const dataStoreTypes = useDataStoreTypes();
const { getDefaultValueForType, mapToAGCellType } = useDataStoreTypes();
const dataStoreStore = useDataStoreStore();
@@ -142,22 +142,6 @@ const setPageSize = async (size: number) => {
await fetchDataStoreContent();
};
const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) => {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(
props.dataStore.id,
props.dataStore.projectId,
column,
);
if (!newColumn) {
throw new Error(i18n.baseText('generic.unknownError'));
}
colDefs.value = [...colDefs.value, createColumnDef(newColumn)];
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
}
};
const onDeleteColumn = async (columnId: string) => {
if (!gridApi.value) return;
@@ -183,7 +167,7 @@ const onDeleteColumn = async (columnId: string) => {
colDefs.value = colDefs.value.filter((def) => def.colId !== columnId);
const rowDataOldValue = [...rowData.value];
rowData.value = rowData.value.map((row) => {
const { [columnToDelete.field ?? '']: _, ...rest } = row;
const { [columnToDelete.field!]: _, ...rest } = row;
return rest;
});
refreshGridData();
@@ -201,7 +185,26 @@ const onDeleteColumn = async (columnId: string) => {
}
};
// TODO: Split this up to create column def based on type
const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) => {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(
props.dataStore.id,
props.dataStore.projectId,
column,
);
if (!newColumn) {
throw new Error(i18n.baseText('generic.unknownError'));
}
colDefs.value = [...colDefs.value, createColumnDef(newColumn)];
rowData.value = rowData.value.map((row) => {
return { ...row, [newColumn.name]: getDefaultValueForType(newColumn.type) };
});
refreshGridData();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
}
};
const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {}) => {
const columnDef: ColDef = {
colId: col.id,
@@ -209,11 +212,11 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
headerName: col.name,
editable: true,
resizable: true,
lockPinned: true,
headerComponent: ColumnHeader,
cellEditorPopup: false,
headerComponentParams: { onDelete: onDeleteColumn },
...extraProps,
cellDataType: dataStoreTypes.mapToAGCellType(col.type),
cellDataType: mapToAGCellType(col.type),
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) {
@@ -284,7 +287,10 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
columnDef.cellEditor = 'agDateCellEditor';
columnDef.cellEditorPopup = true;
}
return columnDef;
return {
...columnDef,
...extraProps,
};
};
const onColumnMoved = async (moveEvent: ColumnMovedEvent) => {
@@ -555,17 +561,19 @@ const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) =>
--ag-font-family: var(--font-family);
--ag-font-size: var(--font-size-xs);
--ag-row-height: calc(var(--ag-grid-size) * 0.8 + 32px);
--ag-header-background-color: var(--color-background-base);
--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-foreground-color: var(--color-text-dark);
--ag-cell-horizontal-padding: var(--spacing-2xs);
--ag-header-column-resize-handle-color: var(--border-color-base);
--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);
:global(.ag-header-cell-resize) {
width: var(--spacing-4xs);
width: var(--spacing-xs);
// this is needed so that we compensate for the width
right: -7px;
}
// Don't show borders for the checkbox cells

View File

@@ -40,7 +40,7 @@ export const DataStoreModule: FrontendModuleDescription = {
},
{
name: PROJECT_DATA_STORES,
path: 'datatables',
path: 'datatables/:new(new)?',
props: true,
components: {
default: DataStoreView,

View File

@@ -98,7 +98,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const activeModules = computed(() => settings.value.activeModules);
const isModuleActive = (moduleName: string) => {
return activeModules.value.includes(moduleName);
return activeModules.value?.includes(moduleName);
};
const partialExecutionVersion = computed<1 | 2>(() => {
@@ -144,6 +144,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isFoldersFeatureEnabled = computed(() => folders.value.enabled);
const isDataStoreFeatureEnabled = computed(() => isModuleActive('data-store'));
const areTagsEnabled = computed(() =>
settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true,
);
@@ -396,5 +398,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isMFAEnforced,
activeModules,
isModuleActive,
isDataStoreFeatureEnabled,
};
});