chore(editor): Rename data store to data table in FE files (no-changelog) (#20427)

This commit is contained in:
Svetoslav Dekov
2025-10-07 14:36:24 +03:00
committed by GitHub
parent 3a78d8cd22
commit 50d63718c9
69 changed files with 1308 additions and 1281 deletions
@@ -56,8 +56,8 @@ describe('DismissBannerRequestDto', () => {
'TRIAL',
'NON_PRODUCTION_LICENSE',
'EMAIL_CONFIRMATION',
'DATA_STORE_STORAGE_LIMIT_WARNING',
'DATA_STORE_STORAGE_LIMIT_ERROR',
'DATA_TABLE_STORAGE_LIMIT_WARNING',
'DATA_TABLE_STORAGE_LIMIT_ERROR',
];
expect(bannerNameSchema.options).toEqual(expectedBanners);
@@ -6,8 +6,8 @@ export const bannerNameSchema = z.enum([
'TRIAL',
'NON_PRODUCTION_LICENSE',
'EMAIL_CONFIRMATION',
'DATA_STORE_STORAGE_LIMIT_WARNING',
'DATA_STORE_STORAGE_LIMIT_ERROR',
'DATA_TABLE_STORAGE_LIMIT_WARNING',
'DATA_TABLE_STORAGE_LIMIT_ERROR',
]);
export type BannerName = z.infer<typeof bannerNameSchema>;
+57 -63
View File
@@ -2925,69 +2925,63 @@
"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.empty.label": "You don't have any data tables yet",
"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",
"dataStore.sort.lastUpdated": "Sort by last updated",
"dataStore.sort.lastCreated": "Sort by last created",
"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 tables",
"dataStore.add.title": "Create new 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",
"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 table.",
"dataStore.noColumns.button.label": "Add first column",
"dataStore.addColumn.label": "Add Column",
"dataStore.addColumn.nameInput.label": "@:_reusableBaseText.name",
"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 exists, choose a different name",
"dataStore.addColumn.systemColumnDescription": "This is a system column, choose a different name",
"dataStore.addColumn.testingColumnDescription": "This column is used for testing, 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.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",
"dataStore.addRow.label": "Add Row",
"dataStore.addRow.error": "Error adding row",
"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.error": "Error deleting rows",
"dataStore.banner.storageLimitWarning.message": "{usage} of Data tables storage used. Delete data to avoid errors",
"dataStore.banner.storageLimitError.message": "{usage} of Data tables storage used, operations may fail. Delete data to avoid errors",
"dataStore.error.tableNotInitialized": "Table not initialized",
"dataStore.noRows": "No rows",
"dataStore.filters.isNull": "Is null",
"dataStore.filters.isNotNull": "Is not null",
"dataStore.filters.isEmpty": "Is empty",
"dataStore.filters.isNotEmpty": "Is not empty",
"dataStore.filters.between": "Between",
"dataStore.filters.true": "True",
"dataStore.filters.false": "False",
"dataStore.filters.greaterThan": "Greater than",
"dataStore.filters.greaterThanOrEqual": "Greater than or equal",
"dataStore.filters.lessThan": "Less than",
"dataStore.filters.lessThanOrEqual": "Less than or equal",
"dataTable.dataTables": "Data tables",
"dataTable.empty.label": "You don't have any data tables yet",
"dataTable.empty.description": "Use data tables to persist execution results, share data between workflows, and track metrics for evaluation.",
"dataTable.card.size": "{size}MB",
"dataTable.card.column.count": "{count} column | {count} columns",
"dataTable.add.title": "Create new Data table",
"dataTable.add.button.label": "Create Data table",
"dataTable.add.input.name.label": "Data table name",
"dataTable.add.input.name.placeholder": "Enter data table name",
"dataTable.add.error": "Error creating data table",
"dataTable.delete.confirm.title": "Delete Data table",
"dataTable.delete.confirm.message": "Are you sure you want to delete the data table '{name}'? This action cannot be undone.",
"dataTable.delete.error": "Error deleting data table",
"dataTable.rename.error": "Error renaming data table",
"dataTable.getDetails.error": "Error fetching data table details",
"dataTable.notFound": "Data table not found",
"dataTable.noColumns.heading": "No columns yet",
"dataTable.noColumns.description": "Add columns to start storing data in this data table.",
"dataTable.noColumns.button.label": "Add first column",
"dataTable.addColumn.label": "Add Column",
"dataTable.addColumn.nameInput.label": "@:_reusableBaseText.name",
"dataTable.addColumn.nameInput.placeholder": "Enter column name",
"dataTable.addColumn.typeInput.label": "@:_reusableBaseText.type",
"dataTable.addColumn.error": "Error adding column",
"dataTable.addColumn.alreadyExistsError": "This column already exists",
"dataTable.addColumn.alreadyExistsDescription": "Column name already exisits, choose a different name",
"dataTable.moveColumn.error": "Error moving column",
"dataTable.deleteColumn.error": "Error deleting column",
"dataTable.deleteColumn.confirm.title": "Delete column",
"dataTable.deleteColumn.confirm.message": "Are you sure you want to delete the column '{name}'? This action cannot be undone.",
"dataTable.addColumn.invalidName.error": "Invalid column name",
"dataTable.addColumn.invalidName.description": "Column names must begin with a letter and can only include letters, numbers, or underscores",
"dataTable.fetchContent.error": "Error fetching data table content",
"dataTable.addRow.label": "Add Row",
"dataTable.addRow.error": "Error adding row",
"dataTable.updateRow.error": "Error updating row",
"dataTable.deleteRows.title": "Delete Rows",
"dataTable.deleteRows.confirmation": "Are you sure you want to delete {count} row? | Are you sure you want to delete {count} rows?",
"dataTable.deleteRows.error": "Error deleting rows",
"dataTable.banner.storageLimitWarning.message": "{usage} of Data tables storage used. Delete data to avoid errors",
"dataTable.banner.storageLimitError.message": "{usage} of Data tables storage used, operations may fail. Delete data to avoid errors",
"dataTable.error.tableNotInitialized": "Table not initialized",
"dataTable.noRows": "No rows",
"dataTable.filters.isNull": "Is null",
"dataTable.filters.isNotNull": "Is not null",
"dataTable.filters.isEmpty": "Is empty",
"dataTable.filters.isNotEmpty": "Is not empty",
"dataTable.filters.between": "Between",
"dataTable.filters.true": "True",
"dataTable.filters.false": "False",
"dataTable.filters.greaterThan": "Greater than",
"dataTable.filters.greaterThanOrEqual": "Greater than or equal",
"dataTable.filters.lessThan": "Less than",
"dataTable.filters.lessThanOrEqual": "Less than or equal",
"dataTable.addColumn.alreadyExistsDescription": "Column name already exists, choose a different name",
"dataTable.addColumn.systemColumnDescription": "This is a system column, choose a different name",
"dataTable.addColumn.testingColumnDescription": "This column is used for testing, choose a different name",
"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>",
@@ -35,7 +35,7 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { useTelemetry } from '@/composables/useTelemetry';
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
import NodeStorageLimitCallout from '@/features/dataStore/components/NodeStorageLimitCallout.vue';
import NodeStorageLimitCallout from '@/features/dataTable/components/NodeStorageLimitCallout.vue';
import NodeTitle from '@/components/NodeTitle.vue';
import { RenameNodeCommand } from '@/models/history';
import { useCredentialsStore } from '@/stores/credentials.store';
@@ -197,7 +197,9 @@ describe('ProjectHeader', () => {
it('should render ProjectTabs Settings if project is team project and user has update scope', () => {
route.params.projectId = '123';
projectsStore.currentProject = createTestProject({ scopes: ['project:update'] });
projectsStore.currentProject = createTestProject({
scopes: ['project:update'],
});
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
@@ -210,7 +212,9 @@ describe('ProjectHeader', () => {
it('should render ProjectTabs without Settings if no project update permission', () => {
route.params.projectId = '123';
projectsStore.currentProject = createTestProject({ scopes: ['project:read'] });
projectsStore.currentProject = createTestProject({
scopes: ['project:read'],
});
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
@@ -281,7 +285,9 @@ describe('ProjectHeader', () => {
});
it('should not render creation button in setting page', async () => {
projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal });
projectsStore.currentProject = createTestProject({
type: ProjectTypes.Personal,
});
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECT_SETTINGS,
} as RouteLocationNormalizedLoadedGeneric);
@@ -467,7 +473,7 @@ describe('ProjectHeader', () => {
expect(actionsContainer.children).toHaveLength(2);
});
it('should not render datastore menu item if data store feature is disabled', () => {
it('should not render dataTable menu item if data table feature is disabled', () => {
settingsStore.isDataTableFeatureEnabled = false;
const { getByTestId } = renderComponent();
const actionsContainer = getByTestId('add-resource-actions');
@@ -19,7 +19,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';
import { PROJECT_DATA_STORES } from '@/features/dataStore/constants';
import { PROJECT_DATA_TABLES } from '@/features/dataTable/constants';
import ReadyToRunV2Button from '@/experiments/readyToRunWorkflowsV2/components/ReadyToRunV2Button.vue';
import { N8nButton, N8nHeading, N8nText, N8nTooltip } from '@n8n/design-system';
@@ -113,7 +113,7 @@ const ACTION_TYPES = {
WORKFLOW: 'workflow',
CREDENTIAL: 'credential',
FOLDER: 'folder',
DATA_STORE: 'dataStore',
DATA_TABLE: 'dataTable',
} as const;
type ActionTypes = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
@@ -150,8 +150,8 @@ const menu = computed(() => {
if (settingsStore.isDataTableFeatureEnabled) {
// 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'),
value: ACTION_TYPES.DATA_TABLE,
label: i18n.baseText('dataTable.add.button.label'),
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes)?.dataStore?.create,
@@ -224,9 +224,9 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
[ACTION_TYPES.FOLDER]: () => {
emit('createFolder');
},
[ACTION_TYPES.DATA_STORE]: (projectId: string) => {
[ACTION_TYPES.DATA_TABLE]: (projectId: string) => {
void router.push({
name: PROJECT_DATA_STORES,
name: PROJECT_DATA_TABLES,
params: { projectId, new: 'new' },
});
},
@@ -4,8 +4,8 @@ import TrialOverBanner from '@/components/banners/TrialOverBanner.vue';
import TrialBanner from '@/components/banners/TrialBanner.vue';
import V1Banner from '@/components/banners/V1Banner.vue';
import EmailConfirmationBanner from '@/components/banners/EmailConfirmationBanner.vue';
import DataStoreStorageLimitWarningBanner from '@/components/banners/DataStoreStorageLimitWarningBanner.vue';
import DataStoreStorageLimitErrorBanner from '@/components/banners/DataStoreStorageLimitErrorBanner.vue';
import DataTableStorageLimitWarningBanner from '@/components/banners/DataTableStorageLimitWarningBanner.vue';
import DataTableStorageLimitErrorBanner from '@/components/banners/DataTableStorageLimitErrorBanner.vue';
import type { Component } from 'vue';
import type { N8nBanners } from '@/Interface';
@@ -19,13 +19,13 @@ export const N8N_BANNERS: N8nBanners = {
EMAIL_CONFIRMATION: { priority: 250, component: EmailConfirmationBanner as Component },
TRIAL: { priority: 150, component: TrialBanner as Component },
NON_PRODUCTION_LICENSE: { priority: 140, component: NonProductionLicenseBanner as Component },
DATA_STORE_STORAGE_LIMIT_WARNING: {
DATA_TABLE_STORAGE_LIMIT_WARNING: {
priority: 300,
component: DataStoreStorageLimitWarningBanner as Component,
component: DataTableStorageLimitWarningBanner as Component,
},
DATA_STORE_STORAGE_LIMIT_ERROR: {
DATA_TABLE_STORAGE_LIMIT_ERROR: {
priority: 400,
component: DataStoreStorageLimitErrorBanner as Component,
component: DataTableStorageLimitErrorBanner as Component,
},
};
</script>
@@ -1,19 +1,19 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const i18n = useI18n();
</script>
<template>
<BaseBanner name="DATA_STORE_STORAGE_LIMIT_ERROR" :dismissible="true" theme="danger">
<BaseBanner name="DATA_TABLE_STORAGE_LIMIT_ERROR" :dismissible="true" theme="danger">
<template #mainContent>
<span>{{
i18n.baseText('dataStore.banner.storageLimitError.message', {
i18n.baseText('dataTable.banner.storageLimitError.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
usage: `${dataTableStore.dataTableSize} / ${dataTableStore.maxSizeMB}MB`,
},
})
}}</span>
@@ -1,19 +1,19 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const i18n = useI18n();
</script>
<template>
<BaseBanner name="DATA_STORE_STORAGE_LIMIT_WARNING" :dismissible="true" theme="warning">
<BaseBanner name="DATA_TABLE_STORAGE_LIMIT_WARNING" :dismissible="true" theme="warning">
<template #mainContent>
<span>{{
i18n.baseText('dataStore.banner.storageLimitWarning.message', {
i18n.baseText('dataTable.banner.storageLimitWarning.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
usage: `${dataTableStore.dataTableSize} / ${dataTableStore.maxSizeMB}MB`,
},
})
}}</span>
@@ -2,11 +2,11 @@ import { computed, ref, type Ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import debounce from 'lodash/debounce';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { useProjectsStore } from '@/stores/projects.store';
import { DATA_STORE_DETAILS, PROJECT_DATA_STORES } from '@/features/dataStore/constants';
import { DATA_TABLE_DETAILS, PROJECT_DATA_TABLES } from '@/features/dataTable/constants';
import type { CommandGroup, CommandBarItem } from './types';
import type { DataStore } from '@/features/dataStore/datastore.types';
import type { DataTable } from '@/features/dataTable/dataTable.types';
import { N8nIcon } from '@n8n/design-system';
const ITEM_ID = {
@@ -21,13 +21,13 @@ export function useDataTableNavigationCommands(options: {
}): CommandGroup {
const i18n = useI18n();
const { lastQuery, activeNodeId, currentProjectName } = options;
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const projectsStore = useProjectsStore();
const router = useRouter();
const route = useRoute();
const dataTableResults = ref<DataStore[]>([]);
const dataTableResults = ref<DataTable[]>([]);
const currentProjectId = computed(() => {
return typeof route.params.projectId === 'string'
@@ -39,7 +39,7 @@ export function useDataTableNavigationCommands(options: {
return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
});
function orderResultByCurrentProjectFirst<T extends DataStore>(results: T[]) {
function orderResultByCurrentProjectFirst<T extends DataTable>(results: T[]) {
return results.sort((a, b) => {
if (a.project?.id === currentProjectId.value) return -1;
if (b.project?.id === currentProjectId.value) return 1;
@@ -56,14 +56,14 @@ export function useDataTableNavigationCommands(options: {
return;
}
await dataStoreStore.fetchDataStores(
await dataTableStore.fetchDataTables(
currentProjectId.value,
1,
100, // TODO: pagination/lazy loading
);
const trimmedLower = trimmed.toLowerCase();
const filtered = dataStoreStore.dataStores.filter((dataTable) =>
const filtered = dataTableStore.dataTables.filter((dataTable) =>
dataTable.name.toLowerCase().includes(trimmedLower),
);
@@ -73,7 +73,7 @@ export function useDataTableNavigationCommands(options: {
}
}, 300);
const getDataTableTitle = (dataTable: DataStore, includeOpenDataTablePrefix: boolean) => {
const getDataTableTitle = (dataTable: DataTable, includeOpenDataTablePrefix: boolean) => {
let prefix = '';
if (dataTable.project && dataTable.project.type === 'personal') {
prefix = includeOpenDataTablePrefix
@@ -92,7 +92,7 @@ export function useDataTableNavigationCommands(options: {
};
const createDataTableCommand = (
dataTable: DataStore,
dataTable: DataTable,
includeOpenDataTablePrefix: boolean,
): CommandBarItem => {
return {
@@ -101,7 +101,7 @@ export function useDataTableNavigationCommands(options: {
section: i18n.baseText('commandBar.sections.dataTables'),
handler: () => {
const targetRoute = router.resolve({
name: DATA_STORE_DETAILS,
name: DATA_TABLE_DETAILS,
params: {
projectId: dataTable.projectId,
id: dataTable.id,
@@ -140,7 +140,7 @@ export function useDataTableNavigationCommands(options: {
handler: () => {
if (!currentProjectId.value) return;
void router.push({
name: PROJECT_DATA_STORES,
name: PROJECT_DATA_TABLES,
params: { projectId: currentProjectId.value, new: 'new' },
});
},
+5 -5
View File
@@ -12,7 +12,7 @@ import type {
} from '@/types';
import type { ComputedRef, InjectionKey, Ref } from 'vue';
import type { ExpressionLocalResolveContext } from './types/expressions';
import { DATA_STORE_MODULE_NAME } from './features/dataStore/constants';
import { DATA_TABLE_MODULE_NAME } from './features/dataTable/constants';
import type { TelemetryContext } from './types/telemetry';
import type { IconName } from '@n8n/design-system/src/components/N8nIcon/icons';
import type { WorkflowState } from './composables/useWorkflowState';
@@ -228,8 +228,8 @@ export const SLACK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.slackTrigger';
export const TELEGRAM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.telegramTrigger';
export const FACEBOOK_LEAD_ADS_TRIGGER_NODE_TYPE = 'n8n-nodes-base.facebookLeadAdsTrigger';
export const RESPOND_TO_WEBHOOK_NODE_TYPE = 'n8n-nodes-base.respondToWebhook';
export const DATA_STORE_NODE_TYPE = 'n8n-nodes-base.dataTable';
export const DATA_STORE_TOOL_NODE_TYPE = 'n8n-nodes-base.dataTableTool';
export const DATA_TABLE_NODE_TYPE = 'n8n-nodes-base.dataTable';
export const DATA_TABLE_TOOL_NODE_TYPE = 'n8n-nodes-base.dataTableTool';
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;
@@ -237,7 +237,7 @@ export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;
// template categories
export const TEMPLATE_CATEGORY_AI = 'categories/ai';
export const DATA_STORE_NODES = [DATA_STORE_NODE_TYPE, DATA_STORE_TOOL_NODE_TYPE];
export const DATA_TABLE_NODES = [DATA_TABLE_NODE_TYPE, DATA_TABLE_TOOL_NODE_TYPE];
export const EXECUTABLE_TRIGGER_NODE_TYPES = [
START_NODE_TYPE,
@@ -260,7 +260,7 @@ export const NODES_USING_CODE_NODE_EDITOR = [
AI_TRANSFORM_NODE_TYPE,
];
export const MODULE_ENABLED_NODES = [
...DATA_STORE_NODES.map((nodeType) => ({ nodeType, module: DATA_STORE_MODULE_NAME })),
...DATA_TABLE_NODES.map((nodeType) => ({ nodeType, module: DATA_TABLE_MODULE_NAME })),
];
export const NODE_POSITION_CONFLICT_ALLOWLIST = [STICKY_NODE_TYPE];
@@ -1,271 +0,0 @@
import { defineStore } from 'pinia';
import { DATA_STORE_STORE } from '@/features/dataStore/constants';
import { computed, ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import {
fetchDataStoresApi,
createDataStoreApi,
deleteDataStoreApi,
updateDataStoreApi,
addDataStoreColumnApi,
deleteDataStoreColumnApi,
moveDataStoreColumnApi,
getDataStoreRowsApi,
insertDataStoreRowApi,
updateDataStoreRowsApi,
deleteDataStoreRowsApi,
fetchDataStoreGlobalLimitInBytes,
} from '@/features/dataStore/dataStore.api';
import type {
DataStore,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
import { useProjectsStore } from '@/stores/projects.store';
import { reorderItem } from '@/features/dataStore/utils';
import { type DataTableSizeStatus } from 'n8n-workflow';
import { useSettingsStore } from '@/stores/settings.store';
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
const rootStore = useRootStore();
const projectStore = useProjectsStore();
const settingsStore = useSettingsStore();
const dataStores = ref<DataStore[]>([]);
const totalCount = ref(0);
const dataStoreSize = ref(0);
const dataStoreSizeLimitState = ref<DataTableSizeStatus>('ok');
const dataStoreTableSizes = ref<Record<string, number>>({});
const formatSize = (sizeBytes: number) => {
return Number((sizeBytes / 1024 / 1024).toFixed(2));
};
const maxSizeMB = computed(() =>
Math.floor(settingsStore.settings?.dataTables?.maxSize / 1024 / 1024),
);
const dataStoreSizes = computed(() => {
const formattedSizes: Record<string, number> = {};
for (const [dataStoreId, sizeBytes] of Object.entries(dataStoreTableSizes.value)) {
formattedSizes[dataStoreId] = formatSize(sizeBytes);
}
return formattedSizes;
});
const fetchDataStores = async (projectId: string, page: number, pageSize: number) => {
const response = await fetchDataStoresApi(rootStore.restApiContext, projectId, {
skip: (page - 1) * pageSize,
take: pageSize,
});
dataStores.value = response.data;
totalCount.value = response.count;
};
const createDataStore = async (name: string, projectId: string) => {
const newStore = await createDataStoreApi(rootStore.restApiContext, name, projectId);
if (!newStore.project && projectId) {
const project = await projectStore.fetchProject(projectId);
if (project) {
newStore.project = project;
}
}
dataStores.value.push(newStore);
totalCount.value += 1;
return newStore;
};
const deleteDataStore = async (datastoreId: string, projectId: string) => {
const deleted = await deleteDataStoreApi(rootStore.restApiContext, datastoreId, projectId);
if (deleted) {
dataStores.value = dataStores.value.filter((store) => store.id !== datastoreId);
totalCount.value -= 1;
}
return deleted;
};
const deleteDataStoreColumn = async (
datastoreId: string,
projectId: string,
columnId: string,
) => {
const deleted = await deleteDataStoreColumnApi(
rootStore.restApiContext,
datastoreId,
projectId,
columnId,
);
if (deleted) {
const index = dataStores.value.findIndex((store) => store.id === datastoreId);
if (index !== -1) {
dataStores.value[index].columns = dataStores.value[index].columns.filter(
(col) => col.id !== columnId,
);
}
}
return deleted;
};
const updateDataStore = async (datastoreId: string, name: string, projectId: string) => {
const updated = await updateDataStoreApi(
rootStore.restApiContext,
datastoreId,
name,
projectId,
);
if (updated) {
const index = dataStores.value.findIndex((store) => store.id === datastoreId);
if (index !== -1) {
dataStores.value[index] = { ...dataStores.value[index], name };
}
}
return updated;
};
const fetchDataStoreDetails = async (datastoreId: string, projectId: string) => {
const response = await fetchDataStoresApi(rootStore.restApiContext, projectId, undefined, {
projectId,
id: datastoreId,
});
if (response.data.length > 0) {
dataStores.value = response.data;
return response.data[0];
}
return null;
};
const fetchOrFindDataStore = async (datastoreId: string, projectId: string) => {
const existingStore = dataStores.value.find((store) => store.id === datastoreId);
if (existingStore) {
return existingStore;
}
return await fetchDataStoreDetails(datastoreId, projectId);
};
const addDataStoreColumn = async (
datastoreId: string,
projectId: string,
column: DataStoreColumnCreatePayload,
) => {
const newColumn = await addDataStoreColumnApi(
rootStore.restApiContext,
datastoreId,
projectId,
column,
);
if (newColumn) {
const index = dataStores.value.findIndex((store) => store.id === datastoreId);
if (index !== -1) {
dataStores.value[index].columns.push(newColumn);
}
}
return newColumn;
};
const moveDataStoreColumn = async (
datastoreId: string,
projectId: string,
columnId: string,
targetIndex: number,
) => {
const moved = await moveDataStoreColumnApi(
rootStore.restApiContext,
datastoreId,
projectId,
columnId,
targetIndex,
);
if (moved) {
const dsIndex = dataStores.value.findIndex((store) => store.id === datastoreId);
const fromIndex = dataStores.value[dsIndex].columns.findIndex((col) => col.id === columnId);
dataStores.value[dsIndex].columns = reorderItem(
dataStores.value[dsIndex].columns,
fromIndex,
targetIndex,
);
}
return moved;
};
const fetchDataStoreContent = async (
datastoreId: string,
projectId: string,
page: number,
pageSize: number,
sortBy: string,
filter?: string,
) => {
return await getDataStoreRowsApi(rootStore.restApiContext, datastoreId, projectId, {
skip: (page - 1) * pageSize,
take: pageSize,
sortBy,
filter,
});
};
const insertEmptyRow = async (dataStoreId: string, projectId: string) => {
const inserted = await insertDataStoreRowApi(
rootStore.restApiContext,
dataStoreId,
{},
projectId,
);
return inserted[0];
};
const updateRow = async (
dataStoreId: string,
projectId: string,
rowId: number,
rowData: DataStoreRow,
) => {
return await updateDataStoreRowsApi(
rootStore.restApiContext,
dataStoreId,
rowId,
rowData,
projectId,
);
};
const deleteRows = async (dataStoreId: string, projectId: string, rowIds: number[]) => {
return await deleteDataStoreRowsApi(rootStore.restApiContext, dataStoreId, rowIds, projectId);
};
const fetchDataStoreSize = async () => {
const result = await fetchDataStoreGlobalLimitInBytes(rootStore.restApiContext);
dataStoreSize.value = formatSize(result.totalBytes);
dataStoreSizeLimitState.value = result.quotaStatus;
const tableSizes: Record<string, number> = {};
for (const [dataStoreId, info] of Object.entries(result.dataTables)) {
tableSizes[dataStoreId] = info.sizeBytes;
}
dataStoreTableSizes.value = tableSizes;
return result;
};
return {
dataStores,
totalCount,
fetchDataStores,
fetchDataStoreSize,
dataStoreSize: computed(() => dataStoreSize.value),
dataStoreSizeLimitState: computed(() => dataStoreSizeLimitState.value),
dataStoreSizes,
maxSizeMB,
createDataStore,
deleteDataStore,
updateDataStore,
fetchDataStoreDetails,
fetchOrFindDataStore,
addDataStoreColumn,
deleteDataStoreColumn,
moveDataStoreColumn,
fetchDataStoreContent,
insertEmptyRow,
updateRow,
deleteRows,
};
});
@@ -1,24 +0,0 @@
import type {
AGGridCellType,
DataStoreValue,
DataStoreColumnType,
} from '@/features/dataStore/datastore.types';
import { AG_GRID_CELL_TYPES, DATA_STORE_COLUMN_TYPES } from '@/features/dataStore/datastore.types';
export const isDataStoreValue = (value: unknown): value is DataStoreValue => {
return (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value instanceof Date
);
};
export const isAGGridCellType = (value: unknown): value is AGGridCellType => {
return typeof value === 'string' && (AG_GRID_CELL_TYPES as readonly string[]).includes(value);
};
export const isDataStoreColumnType = (type: unknown): type is DataStoreColumnType => {
return typeof type === 'string' && (DATA_STORE_COLUMN_TYPES as readonly string[]).includes(type);
};
@@ -1,21 +0,0 @@
import type { BaseResource } from '@/Interface';
import type { DataStore } from '@/features/dataStore/datastore.types';
/**
* Data Store resource type definition
* This extends the ModuleResources interface to add DataStore as a resource type
*/
export type DataStoreResource = BaseResource &
DataStore & {
resourceType: 'datastore';
};
// Extend the ModuleResources interface to include DataStore
declare module '@/Interface' {
interface ModuleResources {
dataStore: DataStoreResource;
}
}
// Export to make this a module
export {};
@@ -1,10 +1,10 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreDetailsView from '@/features/dataStore/DataStoreDetailsView.vue';
import DataTableDetailsView from '@/features/dataTable/DataTableDetailsView.vue';
import { createTestingPinia } from '@pinia/testing';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { useToast } from '@/composables/useToast';
import { useRouter } from 'vue-router';
import type { DataStore } from '@/features/dataStore/datastore.types';
import type { DataTable } from '@/features/dataTable/dataTable.types';
import { waitFor } from '@testing-library/vue';
vi.mock('@/composables/useToast');
@@ -17,9 +17,9 @@ vi.mock('@/composables/useDocumentTitle', () => ({
vi.mock('@n8n/i18n', () => {
const baseText = (key: string) => {
const translations: Record<string, string> = {
'dataStore.getDetails.error': 'Error fetching data store details',
'dataStore.notFound': 'Data store not found',
'dataStore.dataStores': 'Data Stores',
'dataTable.getDetails.error': 'Error fetching data table details',
'dataTable.notFound': 'Data table not found',
'dataTable.dataTables': 'Data Tables',
};
return translations[key] || key;
};
@@ -43,9 +43,9 @@ const mockToast = {
showError: vi.fn(),
};
const DEFAULT_DATA_STORE: DataStore = {
const DEFAULT_DATA_TABLE: DataTable = {
id: 'ds1',
name: 'Test Data Store',
name: 'Test Data Table',
sizeBytes: 2048,
columns: [
{ id: '1', name: 'id', type: 'string', index: 0 },
@@ -56,20 +56,20 @@ const DEFAULT_DATA_STORE: DataStore = {
projectId: 'proj1',
};
const renderComponent = createComponentRenderer(DataStoreDetailsView, {
const renderComponent = createComponentRenderer(DataTableDetailsView, {
props: {
id: 'ds1',
projectId: 'proj1',
},
global: {
stubs: {
DataStoreBreadcrumbs: true,
DataStoreTable: true,
DataTableBreadcrumbs: true,
DataTableTable: true,
},
},
});
describe('DataStoreDetailsView', () => {
describe('DataTableDetailsView', () => {
beforeEach(() => {
(useToast as ReturnType<typeof vi.fn>).mockReturnValue(mockToast);
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue(mockRouter);
@@ -79,34 +79,34 @@ describe('DataStoreDetailsView', () => {
describe('Loading states', () => {
it('should show loading state initially', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockImplementation(
const dataTableStore = useDataTableStore();
vi.spyOn(dataTableStore, 'fetchOrFindDataTable').mockImplementation(
async () => await new Promise(() => {}),
);
const { getByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(getByTestId('data-store-details-loading')).toBeInTheDocument();
expect(getByTestId('data-table-details-loading')).toBeInTheDocument();
});
});
it('should hide loading state after successful data fetch', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE);
const dataTableStore = useDataTableStore();
vi.spyOn(dataTableStore, 'fetchOrFindDataTable').mockResolvedValue(DEFAULT_DATA_TABLE);
const { queryByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument();
expect(queryByTestId('data-table-details-loading')).not.toBeInTheDocument();
});
});
it('should hide loading state after error', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(new Error('Failed'));
const dataTableStore = useDataTableStore();
vi.spyOn(dataTableStore, 'fetchOrFindDataTable').mockRejectedValue(new Error('Failed'));
const { queryByTestId } = renderComponent({ pinia });
@@ -115,7 +115,7 @@ describe('DataStoreDetailsView', () => {
});
await waitFor(() => {
expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument();
expect(queryByTestId('data-table-details-loading')).not.toBeInTheDocument();
});
});
});
@@ -123,21 +123,21 @@ describe('DataStoreDetailsView', () => {
describe('Data rendering', () => {
it('should render breadcrumbs and table when data is loaded', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE);
const dataTableStore = useDataTableStore();
vi.spyOn(dataTableStore, 'fetchOrFindDataTable').mockResolvedValue(DEFAULT_DATA_TABLE);
const { container } = renderComponent({ pinia });
await waitFor(() => {
expect(container.querySelector('data-store-breadcrumbs-stub')).toBeInTheDocument();
expect(container.querySelector('data-store-table-stub')).toBeInTheDocument();
expect(container.querySelector('data-table-breadcrumbs-stub')).toBeInTheDocument();
expect(container.querySelector('data-table-table-stub')).toBeInTheDocument();
});
});
it('should not render content when data store is null', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null);
const dataTableStore = useDataTableStore();
vi.spyOn(dataTableStore, 'fetchOrFindDataTable').mockResolvedValue(null);
const { container } = renderComponent({ pinia });
@@ -145,16 +145,16 @@ describe('DataStoreDetailsView', () => {
expect(mockToast.showError).toHaveBeenCalled();
});
expect(container.querySelector('data-store-breadcrumbs-stub')).not.toBeInTheDocument();
expect(container.querySelector('data-store-table-stub')).not.toBeInTheDocument();
expect(container.querySelector('data-table-breadcrumbs-stub')).not.toBeInTheDocument();
expect(container.querySelector('data-table-table-stub')).not.toBeInTheDocument();
});
});
describe('Error handling', () => {
it('should show error and redirect when data store not found', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null);
const dataTableStore = useDataTableStore();
vi.spyOn(dataTableStore, 'fetchOrFindDataTable').mockResolvedValue(null);
renderComponent({ pinia });
@@ -166,16 +166,16 @@ describe('DataStoreDetailsView', () => {
it('should handle API errors', async () => {
const pinia = createTestingPinia({ stubActions: false });
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const error = new Error('API Error');
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(error);
vi.spyOn(dataTableStore, 'fetchOrFindDataTable').mockRejectedValue(error);
renderComponent({ pinia });
await waitFor(() => {
expect(mockToast.showError).toHaveBeenCalledWith(
error,
'Error fetching data store details',
'Error fetching data table details',
);
expect(mockRouter.push).toHaveBeenCalled();
});
@@ -2,17 +2,17 @@
import { onMounted, ref } from 'vue';
import type {
AddColumnResponse,
DataStore,
DataStoreColumnCreatePayload,
} from '@/features/dataStore/datastore.types';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
DataTable,
DataTableColumnCreatePayload,
} from '@/features/dataTable/dataTable.types';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { DATA_STORE_VIEW, MIN_LOADING_TIME } from '@/features/dataStore/constants';
import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
import { DATA_TABLE_VIEW, MIN_LOADING_TIME } from '@/features/dataTable/constants';
import DataTableBreadcrumbs from '@/features/dataTable/components/DataTableBreadcrumbs.vue';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import DataStoreTable from './components/dataGrid/DataStoreTable.vue';
import DataTableTable from './components/dataGrid/DataTableTable.vue';
import { useDebounce } from '@/composables/useDebounce';
import AddColumnButton from './components/dataGrid/AddColumnButton.vue';
@@ -29,32 +29,32 @@ const i18n = useI18n();
const router = useRouter();
const documentTitle = useDocumentTitle();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const loading = ref(false);
const saving = ref(false);
const dataStore = ref<DataStore | null>(null);
const dataStoreTableRef = ref<InstanceType<typeof DataStoreTable>>();
const dataTable = ref<DataTable | null>(null);
const dataTableTableRef = ref<InstanceType<typeof DataTableTable>>();
const { debounce } = useDebounce();
const showErrorAndGoBackToList = async (error: unknown) => {
if (!(error instanceof Error)) {
error = new Error(String(i18n.baseText('dataStore.getDetails.error')));
error = new Error(String(i18n.baseText('dataTable.getDetails.error')));
}
toast.showError(error, i18n.baseText('dataStore.getDetails.error'));
await router.push({ name: DATA_STORE_VIEW, params: { projectId: props.projectId } });
toast.showError(error, i18n.baseText('dataTable.getDetails.error'));
await router.push({ name: DATA_TABLE_VIEW, params: { projectId: props.projectId } });
};
const initialize = async () => {
loading.value = true;
try {
const response = await dataStoreStore.fetchOrFindDataStore(props.id, props.projectId);
const response = await dataTableStore.fetchOrFindDataTable(props.id, props.projectId);
if (response) {
dataStore.value = response;
documentTitle.set(`${i18n.baseText('dataStore.dataStores')} > ${response.name}`);
dataTable.value = response;
documentTitle.set(`${i18n.baseText('dataTable.dataTables')} > ${response.name}`);
} else {
await showErrorAndGoBackToList(new Error(i18n.baseText('dataStore.notFound')));
await showErrorAndGoBackToList(new Error(i18n.baseText('dataTable.notFound')));
}
} catch (error) {
await showErrorAndGoBackToList(error);
@@ -87,25 +87,25 @@ const onToggleSave = (value: boolean) => {
}
};
const onAddColumn = async (column: DataStoreColumnCreatePayload): Promise<AddColumnResponse> => {
if (!dataStoreTableRef.value) {
const onAddColumn = async (column: DataTableColumnCreatePayload): Promise<AddColumnResponse> => {
if (!dataTableTableRef.value) {
return {
success: false,
errorMessage: i18n.baseText('dataStore.error.tableNotInitialized'),
errorMessage: i18n.baseText('dataTable.error.tableNotInitialized'),
};
}
return await dataStoreTableRef.value.addColumn(column);
return await dataTableTableRef.value.addColumn(column);
};
onMounted(async () => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
documentTitle.set(i18n.baseText('dataTable.dataTables'));
await initialize();
});
</script>
<template>
<div :class="$style['data-store-details-view']">
<div v-if="loading" data-test-id="data-store-details-loading">
<div :class="$style['data-table-details-view']">
<div v-if="loading" data-test-id="data-table-details-loading">
<N8nLoading
variant="h1"
:loading="true"
@@ -115,16 +115,16 @@ onMounted(async () => {
/>
<N8nLoading :loading="true" variant="h1" :rows="10" :shrink-last="false" />
</div>
<div v-else-if="dataStore">
<div v-else-if="dataTable">
<div :class="$style.header">
<DataStoreBreadcrumbs :data-store="dataStore" />
<DataTableBreadcrumbs :data-table="dataTable" />
<div v-if="saving" :class="$style.saving">
<N8nSpinner />
<N8nText>{{ i18n.baseText('generic.saving') }}...</N8nText>
</div>
<div :class="$style.actions">
<N8nButton @click="dataStoreTableRef?.addRow">{{
i18n.baseText('dataStore.addRow.label')
<N8nButton @click="dataTableTableRef?.addRow">{{
i18n.baseText('dataTable.addRow.label')
}}</N8nButton>
<AddColumnButton
:use-text-trigger="true"
@@ -134,9 +134,9 @@ onMounted(async () => {
</div>
</div>
<div :class="$style.content">
<DataStoreTable
ref="dataStoreTableRef"
:data-store="dataStore"
<DataTableTable
ref="dataTableTableRef"
:data-table="dataTable"
@toggle-save="onToggleSave"
/>
</div>
@@ -145,7 +145,7 @@ onMounted(async () => {
</template>
<style lang="scss" module>
.data-store-details-view {
.data-table-details-view {
display: flex;
flex-direction: column;
height: 100%;
@@ -2,13 +2,13 @@ import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { useProjectPages } from '@/composables/useProjectPages';
import { useProjectsStore } from '@/stores/projects.store';
import DataTableView from '@/features/dataTable/DataTableView.vue';
import { useSourceControlStore } from '@/features/sourceControl.ee/sourceControl.store';
import DataStoreView from '@/features/dataStore/DataStoreView.vue';
import { STORES } from '@n8n/stores';
import { createTestingPinia } from '@pinia/testing';
import { createRouter, createWebHistory } from 'vue-router';
import type { DataStoreResource } from '@/features/dataStore/types';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import type { DataTableResource } from '@/features/dataTable/types';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import type { Mock } from 'vitest';
import { type Project } from '@/types/projects.types';
@@ -26,11 +26,11 @@ vi.mock('@n8n/i18n', async (importOriginal) => {
...actualObj,
useI18n: vi.fn(() => ({
baseText: vi.fn((key: string) => {
if (key === 'dataStore.dataStores') return 'Data Stores';
if (key === 'dataTable.dataTables') return 'Data Tables';
if (key === 'projects.menu.personal') return 'Personal';
if (key === 'dataStore.empty.label') return 'No data stores';
if (key === 'dataStore.empty.description') return 'No data stores description';
if (key === 'dataStore.empty.button.label') return 'Create data store';
if (key === 'dataTable.empty.label') return 'No data tables';
if (key === 'dataTable.empty.description') return 'No data tables description';
if (key === 'dataTable.empty.button.label') return 'Create data table';
if (key === 'generic.rename') return 'Rename';
if (key === 'generic.delete') return 'Delete';
if (key === 'generic.clear') return 'Clear';
@@ -85,9 +85,9 @@ const router = createRouter({
let pinia: ReturnType<typeof createTestingPinia>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
let dataStoreStore: ReturnType<typeof mockedStore<typeof useDataStoreStore>>;
let dataTableStore: ReturnType<typeof mockedStore<typeof useDataTableStore>>;
const renderComponent = createComponentRenderer(DataStoreView, {
const renderComponent = createComponentRenderer(DataTableView, {
global: {
plugins: [router],
},
@@ -101,18 +101,18 @@ const initialState = {
},
};
const TEST_DATA_STORE: DataStoreResource = {
const TEST_DATA_TABLE: DataTableResource = {
id: '1',
name: 'Test Data Store',
name: 'Test Data Table',
sizeBytes: 1024,
columns: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'datastore',
resourceType: 'dataTable',
projectId: '1',
};
describe('DataStoreView', () => {
describe('DataTableView', () => {
beforeEach(async () => {
vi.clearAllMocks();
await router.push('/projects/test-project/data-tables');
@@ -121,23 +121,23 @@ describe('DataStoreView', () => {
pinia = createTestingPinia({ initialState });
projectsStore = mockedStore(useProjectsStore);
sourceControlStore = mockedStore(useSourceControlStore);
dataStoreStore = mockedStore(useDataStoreStore);
dataTableStore = mockedStore(useDataTableStore);
// Mock dataStore store state
dataStoreStore.dataStores = [TEST_DATA_STORE];
dataStoreStore.totalCount = 1;
dataStoreStore.fetchDataStores = vi.fn().mockResolvedValue(undefined);
// Mock dataTable store state
dataTableStore.dataTables = [TEST_DATA_TABLE];
dataTableStore.totalCount = 1;
dataTableStore.fetchDataTables = vi.fn().mockResolvedValue(undefined);
projectsStore.currentProjectId = '';
sourceControlStore.isProjectShared = vi.fn(() => false);
});
describe('initialization', () => {
it('should initialize and load data stores from store', async () => {
it('should initialize and load data tables from store', async () => {
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('', 1, 25);
expect(dataTableStore.fetchDataTables).toHaveBeenCalledWith('', 1, 25);
expect(getByTestId('resources-list-wrapper')).toBeInTheDocument();
});
@@ -152,33 +152,33 @@ describe('DataStoreView', () => {
renderComponent({ pinia });
await waitAllPromises();
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25);
expect(dataTableStore.fetchDataTables).toHaveBeenCalledWith('test-project', 1, 25);
});
it('should set document title on mount', async () => {
renderComponent({ pinia });
await waitAllPromises();
expect(mockDocumentTitle.set).toHaveBeenCalledWith('Data Stores');
expect(mockDocumentTitle.set).toHaveBeenCalledWith('Data Tables');
});
it('should handle initialization error', async () => {
const error = new Error('Store Error');
dataStoreStore.fetchDataStores = vi.fn().mockRejectedValue(error);
dataTableStore.fetchDataTables = vi.fn().mockRejectedValue(error);
renderComponent({ pinia });
await waitAllPromises();
expect(mockToast.showError).toHaveBeenCalledWith(error, 'Error loading data stores');
expect(mockToast.showError).toHaveBeenCalledWith(error, 'Error loading data tables');
});
});
describe('empty state', () => {
beforeEach(() => {
dataStoreStore.dataStores = [];
dataStoreStore.totalCount = 0;
dataTableStore.dataTables = [];
dataTableStore.totalCount = 0;
});
it('should show empty state when no data stores exist', async () => {
it('should show empty state when no data tables exist', async () => {
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
@@ -200,12 +200,12 @@ describe('DataStoreView', () => {
});
});
describe('data store cards', () => {
it('should render data store cards', async () => {
describe('data table cards', () => {
it('should render data table cards', async () => {
const { container } = renderComponent({ pinia });
await waitAllPromises();
// Check if DataStoreCard components are rendered
// Check if DataTableCard components are rendered
const cards = container.querySelectorAll('.mb-2xs');
expect(cards.length).toBeGreaterThan(0);
});
@@ -217,7 +217,7 @@ describe('DataStoreView', () => {
await waitAllPromises();
// Clear the initial call
dataStoreStore.fetchDataStores = vi.fn().mockClear();
dataTableStore.fetchDataTables = vi.fn().mockClear();
mockDebounce.callDebounced.mockClear();
// The component should be rendered and ready to handle pagination
@@ -228,18 +228,18 @@ describe('DataStoreView', () => {
});
it('should update page size on pagination change', async () => {
dataStoreStore.dataStores = Array.from({ length: 20 }, (_, i) => ({
...TEST_DATA_STORE,
dataTableStore.dataTables = Array.from({ length: 20 }, (_, i) => ({
...TEST_DATA_TABLE,
id: `${i + 1}`,
name: `Data Store ${i + 1}`,
name: `Data Table ${i + 1}`,
}));
dataStoreStore.totalCount = 20;
dataTableStore.totalCount = 20;
renderComponent({ pinia });
await waitAllPromises();
// Initial call should use default page size of 25
expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('', 1, 25);
expect(dataTableStore.fetchDataTables).toHaveBeenCalledWith('', 1, 25);
});
});
});
@@ -9,19 +9,19 @@ import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store';
import type { SortingAndPaginationUpdates } from '@/Interface';
import type { DataStoreResource } from '@/features/dataStore/types';
import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue';
import type { DataTableResource } from '@/features/dataTable/types';
import DataTableCard from '@/features/dataTable/components/DataTableCard.vue';
import { useSourceControlStore } from '@/features/sourceControl.ee/sourceControl.store';
import {
ADD_DATA_STORE_MODAL_KEY,
DEFAULT_DATA_STORE_PAGE_SIZE,
PROJECT_DATA_STORES,
} from '@/features/dataStore/constants';
ADD_DATA_TABLE_MODAL_KEY,
DEFAULT_DATA_TABLE_PAGE_SIZE,
PROJECT_DATA_TABLES,
} from '@/features/dataTable/constants';
import { useDebounce } from '@/composables/useDebounce';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { N8nActionBox } from '@n8n/design-system';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
@@ -34,7 +34,7 @@ const { callDebounced } = useDebounce();
const documentTitle = useDocumentTitle();
const toast = useToast();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const insightsStore = useInsightsStore();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
@@ -43,18 +43,18 @@ const uiStore = useUIStore();
const loading = ref(true);
const currentPage = ref(1);
const pageSize = ref(DEFAULT_DATA_STORE_PAGE_SIZE);
const pageSize = ref(DEFAULT_DATA_TABLE_PAGE_SIZE);
const dataStoreResources = computed<DataStoreResource[]>(() =>
dataStoreStore.dataStores.map((ds) => {
const dataTableResources = computed<DataTableResource[]>(() =>
dataTableStore.dataTables.map((ds) => {
return {
...ds,
resourceType: 'datastore',
resourceType: 'dataTable',
};
}),
);
const totalCount = computed(() => dataStoreStore.totalCount);
const totalCount = computed(() => dataTableStore.totalCount);
const currentProject = computed(() => {
if (projectPages.isOverviewSubPage) {
@@ -69,9 +69,9 @@ const initialize = async () => {
loading.value = true;
const projectIdFilter = projectPages.isOverviewSubPage ? '' : projectsStore.currentProjectId;
try {
await dataStoreStore.fetchDataStores(projectIdFilter ?? '', currentPage.value, pageSize.value);
await dataTableStore.fetchDataTables(projectIdFilter ?? '', currentPage.value, pageSize.value);
} catch (error) {
toast.showError(error, 'Error loading data stores');
toast.showError(error, 'Error loading data tables');
} finally {
loading.value = false;
}
@@ -91,22 +91,22 @@ const onPaginationUpdate = async (payload: SortingAndPaginationUpdates) => {
const onAddModalClick = () => {
void router.push({
name: PROJECT_DATA_STORES,
name: PROJECT_DATA_TABLES,
params: { projectId: currentProject.value?.id, new: 'new' },
});
};
onMounted(() => {
documentTitle.set(i18n.baseText('dataStore.dataStores'));
documentTitle.set(i18n.baseText('dataTable.dataTables'));
});
watch(
() => route.params.new,
() => {
if (route.params.new === 'new') {
uiStore.openModal(ADD_DATA_STORE_MODAL_KEY);
uiStore.openModal(ADD_DATA_TABLE_MODAL_KEY);
} else {
uiStore.closeModal(ADD_DATA_STORE_MODAL_KEY);
uiStore.closeModal(ADD_DATA_TABLE_MODAL_KEY);
}
},
{ immediate: true },
@@ -115,9 +115,9 @@ watch(
<template>
<ResourcesListLayout
ref="layout"
resource-key="dataStore"
resource-key="dataTable"
type="list-paginated"
:resources="dataStoreResources"
:resources="dataTableResources"
:initialize="initialize"
:type-props="{ itemSize: 80 }"
:loading="loading"
@@ -144,17 +144,17 @@ watch(
<template #empty>
<N8nActionBox
data-test-id="empty-shared-action-box"
:heading="i18n.baseText('dataStore.empty.label')"
:description="i18n.baseText('dataStore.empty.description')"
:button-text="i18n.baseText('dataStore.add.button.label')"
:heading="i18n.baseText('dataTable.empty.label')"
:description="i18n.baseText('dataTable.empty.description')"
:button-text="i18n.baseText('dataTable.add.button.label')"
button-type="secondary"
@click:button="onAddModalClick"
/>
</template>
<template #item="{ item: data }">
<DataStoreCard
<DataTableCard
class="mb-2xs"
:data-store="data as DataStoreResource"
:data-table="data as DataTableResource"
:show-ownership-badge="projectPages.isOverviewSubPage"
:read-only="readOnlyEnv"
/>
@@ -1,11 +1,11 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { onMounted, ref } from 'vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { useUIStore } from '@/stores/ui.store';
import { useToast } from '@/composables/useToast';
import { useRoute, useRouter } from 'vue-router';
import { DATA_STORE_DETAILS, PROJECT_DATA_STORES } from '@/features/dataStore/constants';
import { DATA_TABLE_DETAILS, PROJECT_DATA_TABLES } from '@/features/dataTable/constants';
import { useTelemetry } from '@/composables/useTelemetry';
import { N8nButton, N8nInput, N8nInputLabel } from '@n8n/design-system';
@@ -16,7 +16,7 @@ type Props = {
const props = defineProps<Props>();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const uiStore = useUIStore();
const route = useRoute();
@@ -25,7 +25,7 @@ const i18n = useI18n();
const toast = useToast();
const telemetry = useTelemetry();
const dataStoreName = ref('');
const dataTableName = ref('');
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => {
@@ -37,58 +37,58 @@ onMounted(() => {
const onSubmit = async () => {
try {
const newDataStore = await dataStoreStore.createDataStore(
dataStoreName.value,
const newDataTable = await dataTableStore.createDataTable(
dataTableName.value,
route.params.projectId as string,
);
telemetry.track('User created data table', {
data_table_id: newDataStore.id,
data_table_project_id: newDataStore.project?.id,
data_table_id: newDataTable.id,
data_table_project_id: newDataTable.project?.id,
});
dataStoreName.value = '';
dataTableName.value = '';
uiStore.closeModal(props.modalName);
void router.push({
name: DATA_STORE_DETAILS,
name: DATA_TABLE_DETAILS,
params: {
id: newDataStore.id,
id: newDataTable.id,
},
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.add.error'));
toast.showError(error, i18n.baseText('dataTable.add.error'));
}
};
const onCancel = () => {
uiStore.closeModal(props.modalName);
redirectToDataStores();
redirectToDataTables();
};
const redirectToDataStores = () => {
void router.replace({ name: PROJECT_DATA_STORES });
const redirectToDataTables = () => {
void router.replace({ name: PROJECT_DATA_TABLES });
};
</script>
<template>
<Modal :name="props.modalName" :center="true" width="540px" :before-close="redirectToDataStores">
<Modal :name="props.modalName" :center="true" width="540px" :before-close="redirectToDataTables">
<template #header>
<div :class="$style.header">
<h2>{{ i18n.baseText('dataStore.add.title') }}</h2>
<h2>{{ i18n.baseText('dataTable.add.title') }}</h2>
</div>
</template>
<template #content>
<div :class="$style.content">
<N8nInputLabel
:label="i18n.baseText('dataStore.add.input.name.label')"
:label="i18n.baseText('dataTable.add.input.name.label')"
:required="true"
input-name="dataStoreName"
input-name="dataTableName"
>
<N8nInput
ref="inputRef"
v-model="dataStoreName"
v-model="dataTableName"
type="text"
:placeholder="i18n.baseText('dataStore.add.input.name.placeholder')"
data-test-id="data-store-name-input"
name="dataStoreName"
:placeholder="i18n.baseText('dataTable.add.input.name.placeholder')"
data-test-id="data-table-name-input"
name="dataTableName"
@keyup.enter="onSubmit"
/>
</N8nInputLabel>
@@ -97,15 +97,15 @@ const redirectToDataStores = () => {
<template #footer>
<div :class="$style.footer">
<N8nButton
:disabled="!dataStoreName"
:disabled="!dataTableName"
:label="i18n.baseText('generic.create')"
data-test-id="confirm-add-data-store-button"
data-test-id="confirm-add-data-table-button"
@click="onSubmit"
/>
<N8nButton
type="secondary"
:label="i18n.baseText('generic.cancel')"
data-test-id="cancel-add-data-store-button"
data-test-id="cancel-add-data-table-button"
@click="onCancel"
/>
</div>
@@ -2,10 +2,10 @@ import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
import { DATA_STORE_CARD_ACTIONS } from '@/features/dataStore/constants';
import DataTableActions from '@/features/dataTable/components/DataTableActions.vue';
import { DATA_TABLE_CARD_ACTIONS } from '@/features/dataTable/constants';
import { MODAL_CONFIRM } from '@/constants';
import type { DataStore } from '@/features/dataStore/datastore.types';
import type { DataTable } from '@/features/dataTable/dataTable.types';
const mockMessage = {
confirm: vi.fn(),
@@ -15,7 +15,7 @@ const mockToast = {
showError: vi.fn(),
};
const mockDeleteDataStore = vi.fn();
const mockDeleteDataTable = vi.fn();
vi.mock('@/composables/useMessage', () => ({
useMessage: () => mockMessage,
@@ -25,9 +25,9 @@ vi.mock('@/composables/useToast', () => ({
useToast: () => mockToast,
}));
vi.mock('@/features/dataStore/dataStore.store', () => ({
useDataStoreStore: () => ({
deleteDataStore: mockDeleteDataStore,
vi.mock('@/features/dataTable/dataTable.store', () => ({
useDataTableStore: () => ({
deleteDataTable: mockDeleteDataTable,
}),
}));
@@ -39,19 +39,19 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
if (key === 'generic.delete') return 'Delete';
if (key === 'generic.cancel') return 'Cancel';
if (key === 'generic.unknownError') return 'Something went wrong';
if (key === 'dataStore.delete.confirm.message')
if (key === 'dataTable.delete.confirm.message')
return `Are you sure that you want to delete "${options?.interpolate?.name}"?`;
if (key === 'dataStore.delete.confirm.title') return 'Delete Data Store';
if (key === 'dataStore.delete.error')
return 'Something went wrong while deleting the data store.';
if (key === 'dataTable.delete.confirm.title') return 'Delete Data Table';
if (key === 'dataTable.delete.error')
return 'Something went wrong while deleting the data table.';
return key;
},
}),
}));
const mockDataStore: DataStore = {
const mockDataTable: DataTable = {
id: '1',
name: 'Test DataStore',
name: 'Test DataTable',
sizeBytes: 1024,
columns: [],
createdAt: '2023-01-01T00:00:00.000Z',
@@ -59,18 +59,18 @@ const mockDataStore: DataStore = {
projectId: 'project-1',
};
const renderComponent = createComponentRenderer(DataStoreActions, {
const renderComponent = createComponentRenderer(DataTableActions, {
props: {
dataStore: mockDataStore,
dataTable: mockDataTable,
isReadOnly: false,
location: 'breadcrumbs',
},
});
describe('DataStoreActions', () => {
describe('DataTableActions', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDeleteDataStore.mockResolvedValue(true);
mockDeleteDataTable.mockResolvedValue(true);
mockMessage.confirm.mockResolvedValue(MODAL_CONFIRM);
});
@@ -82,7 +82,7 @@ describe('DataStoreActions', () => {
}),
});
const actionToggle = getByTestId('data-store-card-actions');
const actionToggle = getByTestId('data-table-card-actions');
expect(actionToggle).toBeInTheDocument();
});
@@ -97,7 +97,7 @@ describe('DataStoreActions', () => {
}),
});
const actionToggle = getByTestId('data-store-card-actions');
const actionToggle = getByTestId('data-table-card-actions');
expect(actionToggle).toBeInTheDocument();
});
@@ -110,16 +110,16 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the rename action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.RENAME}`));
await userEvent.click(getByTestId(`action-${DATA_TABLE_CARD_ACTIONS.RENAME}`));
expect(emitted().rename).toBeTruthy();
expect(emitted().rename[0]).toEqual([
{
dataStore: mockDataStore,
dataTable: mockDataTable,
action: 'rename',
},
]);
@@ -134,15 +134,15 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
await userEvent.click(getByTestId(`action-${DATA_TABLE_CARD_ACTIONS.DELETE}`));
expect(mockMessage.confirm).toHaveBeenCalledWith(
'Are you sure that you want to delete "Test DataStore"?',
'Delete Data Store',
'Are you sure that you want to delete "Test DataTable"?',
'Delete Data Table',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
@@ -159,13 +159,13 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
await userEvent.click(getByTestId(`action-${DATA_TABLE_CARD_ACTIONS.DELETE}`));
expect(mockDeleteDataStore).toHaveBeenCalledWith('1', 'project-1');
expect(mockDeleteDataTable).toHaveBeenCalledWith('1', 'project-1');
expect(emitted().onDeleted).toBeTruthy();
});
@@ -180,18 +180,18 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
await userEvent.click(getByTestId(`action-${DATA_TABLE_CARD_ACTIONS.DELETE}`));
expect(mockDeleteDataStore).not.toHaveBeenCalled();
expect(mockDeleteDataTable).not.toHaveBeenCalled();
expect(emitted().onDeleted).toBeFalsy();
});
it('should show error when delete fails', async () => {
mockDeleteDataStore.mockResolvedValue(false);
mockDeleteDataTable.mockResolvedValue(false);
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
@@ -201,21 +201,21 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
await userEvent.click(getByTestId(`action-${DATA_TABLE_CARD_ACTIONS.DELETE}`));
expect(mockToast.showError).toHaveBeenCalledWith(
expect.any(Error),
'Something went wrong while deleting the data store.',
'Something went wrong while deleting the data table.',
);
});
it('should show error when delete throws exception', async () => {
const deleteError = new Error('Delete failed');
mockDeleteDataStore.mockRejectedValue(deleteError);
mockDeleteDataTable.mockRejectedValue(deleteError);
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
@@ -225,15 +225,15 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Click on the delete action
await userEvent.click(getByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`));
await userEvent.click(getByTestId(`action-${DATA_TABLE_CARD_ACTIONS.DELETE}`));
expect(mockToast.showError).toHaveBeenCalledWith(
deleteError,
'Something went wrong while deleting the data store.',
'Something went wrong while deleting the data table.',
);
});
@@ -241,7 +241,7 @@ describe('DataStoreActions', () => {
it('should show rename action when location is breadcrumbs', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
dataStore: mockDataStore,
dataTable: mockDataTable,
isReadOnly: false,
location: 'breadcrumbs',
},
@@ -252,17 +252,17 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-card-actions'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
// Check that rename action is present
expect(queryByTestId(`action-${DATA_STORE_CARD_ACTIONS.RENAME}`)).toBeInTheDocument();
expect(queryByTestId(`action-${DATA_TABLE_CARD_ACTIONS.RENAME}`)).toBeInTheDocument();
});
it('should not show rename action when location is card', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
dataStore: mockDataStore,
dataTable: mockDataTable,
isReadOnly: false,
location: 'card',
},
@@ -273,13 +273,13 @@ describe('DataStoreActions', () => {
});
// Click on the action toggle to open dropdown
await userEvent.click(getByTestId('data-store-card-actions'));
await userEvent.click(getByTestId('data-table-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();
expect(queryByTestId(`action-${DATA_TABLE_CARD_ACTIONS.RENAME}`)).not.toBeInTheDocument();
// But delete action should still be present
expect(queryByTestId(`action-${DATA_STORE_CARD_ACTIONS.DELETE}`)).toBeInTheDocument();
expect(queryByTestId(`action-${DATA_TABLE_CARD_ACTIONS.DELETE}`)).toBeInTheDocument();
});
});
});
@@ -1,18 +1,18 @@
<script setup lang="ts">
import type { DataStore } from '@/features/dataStore/datastore.types';
import type { DataTable } from '@/features/dataTable/dataTable.types';
import type { IUser, UserAction } from '@n8n/design-system';
import { DATA_STORE_CARD_ACTIONS } from '@/features/dataStore/constants';
import { DATA_TABLE_CARD_ACTIONS } from '@/features/dataTable/constants';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import { N8nActionToggle } from '@n8n/design-system';
type Props = {
dataStore: DataStore;
dataTable: DataTable;
isReadOnly?: boolean;
location: 'card' | 'breadcrumbs';
};
@@ -24,14 +24,14 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
rename: [
value: {
dataStore: DataStore;
dataTable: DataTable;
action: string;
},
];
onDeleted: [];
}>();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const i18n = useI18n();
const message = useMessage();
@@ -42,14 +42,14 @@ const actions = computed<Array<UserAction<IUser>>>(() => {
const availableActions = [
{
label: i18n.baseText('generic.delete'),
value: DATA_STORE_CARD_ACTIONS.DELETE,
value: DATA_TABLE_CARD_ACTIONS.DELETE,
disabled: props.isReadOnly,
},
];
if (props.location === 'breadcrumbs') {
availableActions.unshift({
label: i18n.baseText('generic.rename'),
value: DATA_STORE_CARD_ACTIONS.RENAME,
value: DATA_TABLE_CARD_ACTIONS.RENAME,
disabled: props.isReadOnly,
});
}
@@ -58,50 +58,50 @@ const actions = computed<Array<UserAction<IUser>>>(() => {
const onAction = async (action: string) => {
switch (action) {
case DATA_STORE_CARD_ACTIONS.RENAME: {
case DATA_TABLE_CARD_ACTIONS.RENAME: {
// This is handled outside of this component
// where editable label component is used
emit('rename', {
dataStore: props.dataStore,
dataTable: props.dataTable,
action: 'rename',
});
break;
}
case DATA_STORE_CARD_ACTIONS.DELETE: {
case DATA_TABLE_CARD_ACTIONS.DELETE: {
const promptResponse = await message.confirm(
i18n.baseText('dataStore.delete.confirm.message', {
interpolate: { name: props.dataStore.name },
i18n.baseText('dataTable.delete.confirm.message', {
interpolate: { name: props.dataTable.name },
}),
i18n.baseText('dataStore.delete.confirm.title'),
i18n.baseText('dataTable.delete.confirm.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (promptResponse === MODAL_CONFIRM) {
await deleteDataStore();
await deleteDataTable();
}
break;
}
}
};
const deleteDataStore = async () => {
const deleteDataTable = async () => {
try {
const deleted = await dataStoreStore.deleteDataStore(
props.dataStore.id,
props.dataStore.projectId,
const deleted = await dataTableStore.deleteDataTable(
props.dataTable.id,
props.dataTable.projectId,
);
if (!deleted) {
throw new Error(i18n.baseText('generic.unknownError'));
}
emit('onDeleted');
telemetry.track('User deleted data table', {
data_table_id: props.dataStore.id,
data_table_project_id: props.dataStore.projectId,
data_table_id: props.dataTable.id,
data_table_project_id: props.dataTable.projectId,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.delete.error'));
toast.showError(error, i18n.baseText('dataTable.delete.error'));
}
};
</script>
@@ -109,7 +109,7 @@ const deleteDataStore = async () => {
<N8nActionToggle
:actions="actions"
theme="dark"
data-test-id="data-store-card-actions"
data-test-id="data-table-card-actions"
@action="onAction"
/>
</template>
@@ -2,8 +2,8 @@ import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
import type { DataStore } from '@/features/dataStore/datastore.types';
import DataTableBreadcrumbs from '@/features/dataTable/components/DataTableBreadcrumbs.vue';
import type { DataTable } from '@/features/dataTable/dataTable.types';
const mockRouter = {
push: vi.fn(),
@@ -13,7 +13,7 @@ const mockToast = {
showError: vi.fn(),
};
const mockUpdateDataStore = vi.fn();
const mockUpdateDataTable = vi.fn();
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
@@ -27,9 +27,9 @@ vi.mock('@/composables/useToast', () => ({
useToast: () => mockToast,
}));
vi.mock('@/features/dataStore/dataStore.store', () => ({
useDataStoreStore: () => ({
updateDataStore: mockUpdateDataStore,
vi.mock('@/features/dataTable/dataTable.store', () => ({
useDataTableStore: () => ({
updateDataTable: mockUpdateDataTable,
}),
}));
@@ -38,9 +38,9 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
useI18n: () => ({
baseText: (key: string) => {
const translations: Record<string, string> = {
'dataStore.dataStores': 'Data Stores',
'dataStore.add.input.name.label': 'Data store name',
'dataStore.rename.error': 'Something went wrong while renaming the data store.',
'dataTable.dataTables': 'Data Tables',
'dataTable.add.input.name.label': 'Data table name',
'dataTable.rename.error': 'Something went wrong while renaming the data table.',
'generic.unknownError': 'Something went wrong',
};
return translations[key] || key;
@@ -48,9 +48,9 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
}),
}));
const mockDataStore: DataStore = {
const mockDataTable: DataTable = {
id: '1',
name: 'Test DataStore',
name: 'Test DataTable',
sizeBytes: 1024,
columns: [],
createdAt: '2023-01-01T00:00:00.000Z',
@@ -68,21 +68,21 @@ const mockDataStore: DataStore = {
},
};
const mockDataStoreWithoutProject: DataStore = {
...mockDataStore,
const mockDataTableWithoutProject: DataTable = {
...mockDataTable,
project: undefined,
};
const renderComponent = createComponentRenderer(DataStoreBreadcrumbs, {
const renderComponent = createComponentRenderer(DataTableBreadcrumbs, {
props: {
dataStore: mockDataStore,
dataTable: mockDataTable,
},
});
describe('DataStoreBreadcrumbs', () => {
describe('DataTableBreadcrumbs', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateDataStore.mockResolvedValue(true);
mockUpdateDataTable.mockResolvedValue(true);
});
describe('Breadcrumbs rendering', () => {
@@ -94,7 +94,7 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
expect(getByText('Data Stores')).toBeInTheDocument();
expect(getByText('Data Tables')).toBeInTheDocument();
const separators = getAllByText('/');
expect(separators.length).toBeGreaterThan(0);
});
@@ -102,7 +102,7 @@ describe('DataStoreBreadcrumbs', () => {
it('should render breadcrumbs component when project is null', () => {
const { container } = renderComponent({
props: {
dataStore: mockDataStoreWithoutProject,
dataTable: mockDataTableWithoutProject,
},
pinia: createTestingPinia({
initialState: {},
@@ -111,11 +111,11 @@ describe('DataStoreBreadcrumbs', () => {
});
// Should still render the breadcrumbs container even without project
const breadcrumbsContainer = container.querySelector('.data-store-breadcrumbs');
const breadcrumbsContainer = container.querySelector('.data-table-breadcrumbs');
expect(breadcrumbsContainer).toBeInTheDocument();
});
it('should render inline text edit for datastore name', () => {
it('should render inline text edit for data table name', () => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: {},
@@ -123,11 +123,11 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
const nameInput = getByTestId('datastore-header-name-input');
const nameInput = getByTestId('data-table-header-name-input');
expect(nameInput).toBeInTheDocument();
});
it('should render DataStoreActions component', () => {
it('should render DataTableActions component', () => {
const { container } = renderComponent({
pinia: createTestingPinia({
initialState: {},
@@ -135,13 +135,13 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
const actionsComponent = container.querySelector('[data-test-id="data-store-card-actions"]');
const actionsComponent = container.querySelector('[data-test-id="data-table-card-actions"]');
expect(actionsComponent).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should navigate to datastores list when breadcrumb item is clicked', async () => {
it('should navigate to data tables list when breadcrumb item is clicked', async () => {
const { getByText } = renderComponent({
pinia: createTestingPinia({
initialState: {},
@@ -149,13 +149,13 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
const datastoresLink = getByText('Data Stores');
await userEvent.click(datastoresLink);
const dataTablesLink = getByText('Data Tables');
await userEvent.click(dataTablesLink);
expect(mockRouter.push).toHaveBeenCalledWith('/projects/project-1/datatables');
});
it('should render DataStoreActions component that can trigger navigation', () => {
it('should render DataTableActions component that can trigger navigation', () => {
const { container } = renderComponent({
pinia: createTestingPinia({
initialState: {},
@@ -163,14 +163,14 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
// Verify DataStoreActions component is rendered
const actionsComponent = container.querySelector('[data-test-id="data-store-card-actions"]');
// Verify DataTableActions component is rendered
const actionsComponent = container.querySelector('[data-test-id="data-table-card-actions"]');
expect(actionsComponent).toBeInTheDocument();
});
});
describe('Name editing', () => {
it('should show current datastore name', () => {
it('should show current data table name', () => {
const { getByDisplayValue } = renderComponent({
pinia: createTestingPinia({
initialState: {},
@@ -178,7 +178,7 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
expect(getByDisplayValue('Test DataStore')).toBeInTheDocument();
expect(getByDisplayValue('Test DataTable')).toBeInTheDocument();
});
});
@@ -192,19 +192,19 @@ describe('DataStoreBreadcrumbs', () => {
});
// Check main structure
const breadcrumbsContainer = container.querySelector('.data-store-breadcrumbs');
const breadcrumbsContainer = container.querySelector('.data-table-breadcrumbs');
expect(breadcrumbsContainer).toBeInTheDocument();
// Check name input
const nameInput = getByTestId('datastore-header-name-input');
const nameInput = getByTestId('data-table-header-name-input');
expect(nameInput).toBeInTheDocument();
// Check actions component
const actionsComponent = container.querySelector('[data-test-id="data-store-card-actions"]');
const actionsComponent = container.querySelector('[data-test-id="data-table-card-actions"]');
expect(actionsComponent).toBeInTheDocument();
});
it('should display correct datastore name', () => {
it('should display correct data table name', () => {
const { getByDisplayValue } = renderComponent({
pinia: createTestingPinia({
initialState: {},
@@ -212,7 +212,7 @@ describe('DataStoreBreadcrumbs', () => {
}),
});
expect(getByDisplayValue('Test DataStore')).toBeInTheDocument();
expect(getByDisplayValue('Test DataTable')).toBeInTheDocument();
});
it('should show breadcrumbs separator', () => {
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import type { DataStore } from '@/features/dataStore/datastore.types';
import type { DataTable } from '@/features/dataTable/dataTable.types';
import { useI18n } from '@n8n/i18n';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useRouter } from 'vue-router';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
import { PROJECT_DATA_STORES } from '@/features/dataStore/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import DataTableActions from '@/features/dataTable/components/DataTableActions.vue';
import { PROJECT_DATA_TABLES } from '@/features/dataTable/constants';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { useToast } from '@/composables/useToast';
import { telemetry } from '@/plugins/telemetry';
@@ -15,23 +15,23 @@ import ProjectBreadcrumb from '@/components/Folders/ProjectBreadcrumb.vue';
const BREADCRUMBS_SEPARATOR = '/';
type Props = {
dataStore: DataStore;
dataTable: DataTable;
};
const props = defineProps<Props>();
const renameInput = useTemplateRef<{ forceFocus: () => void }>('renameInput');
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const i18n = useI18n();
const router = useRouter();
const toast = useToast();
const editableName = ref(props.dataStore.name);
const editableName = ref(props.dataTable.name);
const project = computed(() => {
return props.dataStore.project ?? null;
return props.dataTable.project ?? null;
});
const breadcrumbs = computed<PathItem[]>(() => {
@@ -40,8 +40,8 @@ const breadcrumbs = computed<PathItem[]>(() => {
}
return [
{
id: 'datastores',
label: i18n.baseText('dataStore.dataStores'),
id: 'datatables',
label: i18n.baseText('dataTable.dataTables'),
href: `/projects/${project.value.id}/datatables`,
},
];
@@ -55,8 +55,8 @@ const onItemClicked = async (item: PathItem) => {
const onDelete = async () => {
await router.push({
name: PROJECT_DATA_STORES,
params: { projectId: props.dataStore.projectId },
name: PROJECT_DATA_TABLES,
params: { projectId: props.dataTable.projectId },
});
};
@@ -71,28 +71,28 @@ const onRename = async () => {
const onNameSubmit = async (name: string) => {
try {
const updated = await dataStoreStore.updateDataStore(
props.dataStore.id,
const updated = await dataTableStore.updateDataTable(
props.dataTable.id,
name,
props.dataStore.projectId,
props.dataTable.projectId,
);
if (!updated) {
throw new Error(i18n.baseText('generic.unknownError'));
}
editableName.value = name;
telemetry.track('User renamed data table', {
data_table_id: props.dataStore.id,
data_table_project_id: props.dataStore.projectId,
data_table_id: props.dataTable.id,
data_table_project_id: props.dataTable.projectId,
});
} catch (error) {
// Revert to original name if rename fails
editableName.value = props.dataStore.name;
toast.showError(error, i18n.baseText('dataStore.rename.error'));
editableName.value = props.dataTable.name;
toast.showError(error, i18n.baseText('dataTable.rename.error'));
}
};
watch(
() => props.dataStore.name,
() => props.dataTable.name,
(newName) => {
editableName.value = newName;
},
@@ -100,7 +100,7 @@ watch(
</script>
<template>
<div :class="$style['data-store-breadcrumbs']">
<div :class="$style['data-table-breadcrumbs']">
<N8nBreadcrumbs
:items="breadcrumbs"
:separator="BREADCRUMBS_SEPARATOR"
@@ -115,8 +115,8 @@ watch(
<N8nInlineTextEdit
ref="renameInput"
v-model="editableName"
data-test-id="datastore-header-name-input"
:placeholder="i18n.baseText('dataStore.add.input.name.label')"
data-test-id="data-table-header-name-input"
:placeholder="i18n.baseText('dataTable.add.input.name.label')"
:class="$style['breadcrumb-current']"
:max-length="30"
:read-only="false"
@@ -125,9 +125,9 @@ watch(
/>
</template>
</N8nBreadcrumbs>
<div :class="$style['data-store-actions']">
<DataStoreActions
:data-store="props.dataStore"
<div :class="$style['data-table-actions']">
<DataTableActions
:data-table="props.dataTable"
location="breadcrumbs"
@rename="onRename"
@on-deleted="onDelete"
@@ -137,12 +137,12 @@ watch(
</template>
<style lang="scss" module>
.data-store-breadcrumbs {
.data-table-breadcrumbs {
display: flex;
align-items: center;
}
.data-store-actions {
.data-table-actions {
position: relative;
top: var(--spacing-5xs);
}
@@ -1,7 +1,7 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue';
import DataTableCard from '@/features/dataTable/components/DataTableCard.vue';
import { createPinia, setActivePinia } from 'pinia';
import type { DataStoreResource } from '@/features/dataStore/types';
import type { DataTableResource } from '@/features/dataTable/types';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -22,27 +22,27 @@ vi.mock('vue-router', () => {
};
});
const DEFAULT_DATA_STORE: DataStoreResource = {
const DEFAULT_DATA_TABLE: DataTableResource = {
id: '1',
name: 'Test Data Store',
name: 'Test Data Table',
sizeBytes: 1024,
columns: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'datastore',
resourceType: 'dataTable',
projectId: '1',
} as const satisfies DataStoreResource;
} as const satisfies DataTableResource;
const renderComponent = createComponentRenderer(DataStoreCard, {
const renderComponent = createComponentRenderer(DataTableCard, {
props: {
dataStore: DEFAULT_DATA_STORE,
dataTable: DEFAULT_DATA_TABLE,
readOnly: false,
showOwnershipBadge: false,
},
global: {
stubs: {
N8nLink: {
template: '<a :href="href" data-test-id="data-store-card-link"><slot /></a>',
template: '<a :href="href" data-test-id="data-table-card-link"><slot /></a>',
props: ['to'],
computed: {
href() {
@@ -61,7 +61,7 @@ const renderComponent = createComponentRenderer(DataStoreCard, {
},
});
describe('DataStoreCard', () => {
describe('DataTableCard', () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(async () => {
@@ -73,13 +73,13 @@ describe('DataStoreCard', () => {
vi.clearAllMocks();
});
it('should render data store info correctly', () => {
it('should render data table info correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-card-icon')).toBeInTheDocument();
expect(getByTestId('data-store-card-name')).toHaveTextContent(DEFAULT_DATA_STORE.name);
expect(getByTestId('data-store-card-column-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-last-updated')).toHaveTextContent('Last updated');
expect(getByTestId('data-store-card-created')).toHaveTextContent('Created');
expect(getByTestId('data-table-card-icon')).toBeInTheDocument();
expect(getByTestId('data-table-card-name')).toHaveTextContent(DEFAULT_DATA_TABLE.name);
expect(getByTestId('data-table-card-column-count')).toBeInTheDocument();
expect(getByTestId('data-table-card-last-updated')).toHaveTextContent('Last updated');
expect(getByTestId('data-table-card-created')).toHaveTextContent('Created');
});
it('should not render readonly badge when not readonly', () => {
@@ -96,20 +96,20 @@ describe('DataStoreCard', () => {
expect(getByText('Read only')).toBeInTheDocument();
});
it('should render correct route to data store details', () => {
it('should render correct route to data table details', () => {
const wrapper = renderComponent();
const link = wrapper.getByTestId('data-store-card-link');
const link = wrapper.getByTestId('data-table-card-link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
`/projects/${DEFAULT_DATA_STORE.projectId}/datatables/${DEFAULT_DATA_STORE.id}`,
`/projects/${DEFAULT_DATA_TABLE.projectId}/datatables/${DEFAULT_DATA_TABLE.id}`,
);
});
it('should display column count information', () => {
const { getByTestId } = renderComponent();
const columnCountElement = getByTestId('data-store-card-column-count');
const columnCountElement = getByTestId('data-table-card-column-count');
expect(columnCountElement).toBeInTheDocument();
expect(columnCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.columns.length + 1}`);
expect(columnCountElement).toHaveTextContent(`${DEFAULT_DATA_TABLE.columns.length + 1}`);
});
});
@@ -1,21 +1,21 @@
<script setup lang="ts">
import type { DataStoreResource } from '@/features/dataStore/types';
import { DATA_STORE_DETAILS } from '@/features/dataStore/constants';
import type { DataTable } from '@/features/dataTable/dataTable.types';
import { DATA_TABLE_DETAILS } from '@/features/dataTable/constants';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import DataTableActions from '@/features/dataTable/components/DataTableActions.vue';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import TimeAgo from '@/components/TimeAgo.vue';
import { N8nBadge, N8nCard, N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
type Props = {
dataStore: DataStoreResource;
dataTable: DataTable;
readOnly?: boolean;
showOwnershipBadge?: boolean;
};
const i18n = useI18n();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const props = withDefaults(defineProps<Props>(), {
actions: () => [],
@@ -23,28 +23,28 @@ const props = withDefaults(defineProps<Props>(), {
showOwnershipBadge: false,
});
const dataStoreRoute = computed(() => {
const dataTableRoute = computed(() => {
return {
name: DATA_STORE_DETAILS,
name: DATA_TABLE_DETAILS,
params: {
projectId: props.dataStore.projectId,
id: props.dataStore.id,
projectId: props.dataTable.projectId,
id: props.dataTable.id,
},
};
});
const getDataStoreSize = computed(() => {
const size = dataStoreStore.dataStoreSizes[props.dataStore.id] ?? 0;
const getDataTableSize = computed(() => {
const size = dataTableStore.dataTableSizes[props.dataTable.id] ?? 0;
return size;
});
</script>
<template>
<div data-test-id="data-store-card">
<N8nLink :to="dataStoreRoute" class="data-store-card" data-test-id="data-store-card-link">
<div data-test-id="data-table-card">
<N8nLink :to="dataTableRoute" class="data-table-card" data-test-id="data-table-card-link">
<N8nCard :class="$style.card">
<template #prepend>
<N8nIcon
data-test-id="data-store-card-icon"
data-test-id="data-table-card-icon"
:class="$style['card-icon']"
icon="database"
size="xlarge"
@@ -53,8 +53,8 @@ const getDataStoreSize = computed(() => {
</template>
<template #header>
<div :class="$style['card-header']">
<N8nText tag="h2" bold data-test-id="data-store-card-name">
{{ props.dataStore.name }}
<N8nText tag="h2" bold data-test-id="data-table-card-name">
{{ props.dataTable.name }}
</N8nText>
<N8nBadge v-if="props.readOnly" class="ml-3xs" theme="tertiary" bold>
{{ i18n.baseText('workflows.item.readonly') }}
@@ -67,11 +67,11 @@ const getDataStoreSize = computed(() => {
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--size']]"
data-test-id="data-store-card-size"
data-test-id="data-table-card-size"
>
{{
i18n.baseText('dataStore.card.size', {
interpolate: { size: getDataStoreSize },
i18n.baseText('dataTable.card.size', {
interpolate: { size: getDataTableSize },
})
}}
</N8nText>
@@ -79,11 +79,11 @@ const getDataStoreSize = computed(() => {
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--column-count']]"
data-test-id="data-store-card-column-count"
data-test-id="data-table-card-column-count"
>
{{
i18n.baseText('dataStore.card.column.count', {
interpolate: { count: props.dataStore.columns.length + 1 },
i18n.baseText('dataTable.card.column.count', {
interpolate: { count: props.dataTable.columns.length + 1 },
})
}}
</N8nText>
@@ -91,26 +91,26 @@ const getDataStoreSize = computed(() => {
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--updated']]"
data-test-id="data-store-card-last-updated"
data-test-id="data-table-card-last-updated"
>
{{ i18n.baseText('workerList.item.lastUpdated') }}
<TimeAgo :date="String(props.dataStore.updatedAt)" />
<TimeAgo :date="String(props.dataTable.updatedAt)" />
</N8nText>
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--created']]"
data-test-id="data-store-card-created"
data-test-id="data-table-card-created"
>
{{ i18n.baseText('workflows.item.created') }}
<TimeAgo :date="String(props.dataStore.createdAt)" />
<TimeAgo :date="String(props.dataTable.createdAt)" />
</N8nText>
</div>
</template>
<template #append>
<div :class="$style['card-actions']" @click.prevent>
<DataStoreActions
:data-store="props.dataStore"
<DataTableActions
:data-table="props.dataTable"
:is-read-only="props.readOnly"
location="card"
/>
@@ -2,20 +2,20 @@
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useNDVStore } from '@/stores/ndv.store';
import { DATA_STORE_NODES } from '@/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { DATA_TABLE_NODES } from '@/constants';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { N8nCallout } from '@n8n/design-system';
const i18n = useI18n();
const nvdStore = useNDVStore();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const calloutType = computed(() => {
if (!DATA_STORE_NODES.includes(nvdStore.activeNode?.type ?? '')) {
if (!DATA_TABLE_NODES.includes(nvdStore.activeNode?.type ?? '')) {
return null;
}
const sizeLimitState = dataStoreStore.dataStoreSizeLimitState;
const sizeLimitState = dataTableStore.dataTableSizeLimitState;
switch (sizeLimitState) {
case 'error':
return 'danger';
@@ -30,18 +30,18 @@ const calloutType = computed(() => {
<N8nCallout v-if="calloutType" :theme="calloutType" class="mt-xs">
<span v-if="calloutType === 'danger'">
{{
i18n.baseText('dataStore.banner.storageLimitError.message', {
i18n.baseText('dataTable.banner.storageLimitError.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
usage: `${dataTableStore.dataTableSize} / ${dataTableStore.maxSizeMB}MB`,
},
})
}}
</span>
<span v-else>
{{
i18n.baseText('dataStore.banner.storageLimitWarning.message', {
i18n.baseText('dataTable.banner.storageLimitWarning.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
usage: `${dataTableStore.dataTableSize} / ${dataTableStore.maxSizeMB}MB`,
},
})
}}
@@ -1,11 +1,11 @@
import { createComponentRenderer } from '@/__tests__/render';
import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue';
import AddColumnButton from '@/features/dataTable/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';
import { MAX_COLUMN_NAME_LENGTH } from '@/features/dataTable/constants';
vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
useDataStoreTypes: () => ({
vi.mock('@/features/dataTable/composables/useDataTableTypes', () => ({
useDataTableTypes: () => ({
getIconForType: (type: string) => {
const iconMap: Record<string, string> = {
string: 'abc',
@@ -29,12 +29,12 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
useI18n: () => ({
baseText: (key: string) => {
const translations: Record<string, string> = {
'dataStore.addColumn.label': 'Add column',
'dataStore.addColumn.nameInput.label': 'Column name',
'dataStore.addColumn.nameInput.placeholder': 'Enter column name',
'dataStore.addColumn.typeInput.label': 'Column type',
'dataStore.addColumn.invalidName.error': 'Invalid column name',
'dataStore.addColumn.invalidName.description':
'dataTable.addColumn.label': 'Add column',
'dataTable.addColumn.nameInput.label': 'Column name',
'dataTable.addColumn.nameInput.placeholder': 'Enter column name',
'dataTable.addColumn.typeInput.label': 'Column type',
'dataTable.addColumn.invalidName.error': 'Invalid column name',
'dataTable.addColumn.invalidName.description':
'Column names must start with a letter and contain only letters, numbers, and hyphens',
};
return translations[key] || key;
@@ -58,12 +58,12 @@ describe('AddColumnButton', () => {
it('should render the add column button', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-add-column-trigger-button')).toBeInTheDocument();
expect(getByTestId('data-table-add-column-trigger-button')).toBeInTheDocument();
});
it('should focus name input when popover opens', async () => {
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -75,14 +75,14 @@ describe('AddColumnButton', () => {
it('should call addColumn with correct payload', async () => {
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'newColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
expect(submitButton).not.toBeDisabled();
await fireEvent.click(submitButton);
@@ -94,19 +94,19 @@ describe('AddColumnButton', () => {
it('should disable submit button when name is empty', async () => {
const { getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
await waitFor(() => {
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
expect(submitButton).toBeDisabled();
});
});
it('should show error for invalid column names', async () => {
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -118,14 +118,14 @@ describe('AddColumnButton', () => {
await waitFor(() => {
expect(getByText('Invalid column name')).toBeInTheDocument();
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
expect(submitButton).toBeDisabled();
});
});
it('should allow valid column names', async () => {
const { getByTestId, getByPlaceholderText, queryByText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -146,7 +146,7 @@ describe('AddColumnButton', () => {
it('should clear error when correcting invalid name', async () => {
const { getByTestId, getByPlaceholderText, getByText, queryByText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -171,7 +171,7 @@ describe('AddColumnButton', () => {
it('should respect max column name length', async () => {
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -182,7 +182,7 @@ describe('AddColumnButton', () => {
it('should allow selecting different column types', async () => {
const { getByPlaceholderText, getByRole, getByText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -197,7 +197,7 @@ describe('AddColumnButton', () => {
const numberOption = getByText('number');
await fireEvent.click(numberOption);
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
await fireEvent.click(submitButton);
expect(addColumnHandler).toHaveBeenCalledWith({
@@ -208,14 +208,14 @@ describe('AddColumnButton', () => {
it('should reset form after successful submission', async () => {
const { getByPlaceholderText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'testColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
await fireEvent.click(submitButton);
// Click button again to open popover
@@ -229,14 +229,14 @@ describe('AddColumnButton', () => {
it('should close popover after successful submission', async () => {
const { getByPlaceholderText, getByTestId, queryByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'testColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
await fireEvent.click(submitButton);
await waitFor(() => {
@@ -250,14 +250,14 @@ describe('AddColumnButton', () => {
success: false,
error: 'Column name already exists',
});
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
const nameInput = getByPlaceholderText('Enter column name');
await fireEvent.update(nameInput, 'testColumn');
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
await fireEvent.click(submitButton);
await waitFor(() => {
@@ -267,7 +267,7 @@ describe('AddColumnButton', () => {
it('should allow submission with Enter key', async () => {
const { getByTestId, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -283,7 +283,7 @@ describe('AddColumnButton', () => {
it('should display all column type options', async () => {
const { getByTestId, getByRole, getByText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -300,7 +300,7 @@ describe('AddColumnButton', () => {
it('should set value to "date" when selecting "datetime" option', async () => {
const { getByTestId, getByRole, getByText, getByPlaceholderText } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -313,7 +313,7 @@ describe('AddColumnButton', () => {
const dateOption = getByText('datetime');
await fireEvent.click(dateOption);
const submitButton = getByTestId('data-store-add-column-submit-button');
const submitButton = getByTestId('data-table-add-column-submit-button');
await fireEvent.click(submitButton);
expect(addColumnHandler).toHaveBeenCalledWith({
@@ -324,7 +324,7 @@ describe('AddColumnButton', () => {
it('should show tooltip with error description', async () => {
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
const addButton = getByTestId('data-store-add-column-trigger-button');
const addButton = getByTestId('data-table-add-column-trigger-button');
await fireEvent.click(addButton);
@@ -2,13 +2,13 @@
import { computed, nextTick, ref } from 'vue';
import type {
AddColumnResponse,
DataStoreColumnCreatePayload,
DataStoreColumnType,
} from '@/features/dataStore/datastore.types';
import { DATA_STORE_COLUMN_TYPES } from '@/features/dataStore/datastore.types';
DataTableColumnCreatePayload,
DataTableColumnType,
} from '@/features/dataTable/dataTable.types';
import { DATA_TABLE_COLUMN_TYPES } from '@/features/dataTable/dataTable.types';
import { useI18n } from '@n8n/i18n';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
import { useDataTableTypes } from '@/features/dataTable/composables/useDataTableTypes';
import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataTable/constants';
import { useDebounce } from '@/composables/useDebounce';
import {
@@ -31,22 +31,22 @@ type FormError = {
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<AddColumnResponse>;
onAddColumn: (column: DataTableColumnCreatePayload) => Promise<AddColumnResponse>;
};
popoverId?: string;
useTextTrigger?: boolean;
}>();
const i18n = useI18n();
const { getIconForType } = useDataStoreTypes();
const { getIconForType } = useDataTableTypes();
const { debounce } = useDebounce();
const nameInputRef = ref<HTMLInputElement | null>(null);
const columnName = ref('');
const columnType = ref<DataStoreColumnType>('string');
const columnType = ref<DataTableColumnType>('string');
const columnTypes: DataStoreColumnType[] = [...DATA_STORE_COLUMN_TYPES];
const columnTypes: DataTableColumnType[] = [...DATA_TABLE_COLUMN_TYPES];
const error = ref<FormError | null>(null);
@@ -76,18 +76,18 @@ const onAddButtonClicked = async () => {
});
if (!response.success) {
let errorMessage = i18n.baseText('dataStore.addColumn.error');
let errorMessage = i18n.baseText('dataTable.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', {
errorMessage = i18n.baseText('dataTable.addColumn.alreadyExistsError', {
interpolate: { name: columnName.value },
});
errorDescription = response.errorMessage?.includes('system')
? i18n.baseText('dataStore.addColumn.systemColumnDescription')
? i18n.baseText('dataTable.addColumn.systemColumnDescription')
: response.errorMessage?.includes('testing')
? i18n.baseText('dataStore.addColumn.testingColumnDescription')
: i18n.baseText('dataStore.addColumn.alreadyExistsDescription');
? i18n.baseText('dataTable.addColumn.testingColumnDescription')
: i18n.baseText('dataTable.addColumn.alreadyExistsDescription');
}
error.value = {
message: errorMessage,
@@ -121,8 +121,8 @@ const validateName = () => {
}
if (columnName.value && !COLUMN_NAME_REGEX.test(columnName.value)) {
error.value = {
message: i18n.baseText('dataStore.addColumn.invalidName.error'),
description: i18n.baseText('dataStore.addColumn.invalidName.description'),
message: i18n.baseText('dataTable.addColumn.invalidName.error'),
description: i18n.baseText('dataTable.addColumn.invalidName.description'),
};
}
};
@@ -131,7 +131,7 @@ const onInput = debounce(validateName, { debounceTime: 100 });
</script>
<template>
<N8nTooltip :disabled="popoverOpen" :content="i18n.baseText('dataStore.addColumn.label')">
<N8nTooltip :disabled="popoverOpen" :content="i18n.baseText('dataTable.addColumn.label')">
<div class="add-column-header-component-wrapper">
<N8nPopoverReka
:id="popoverId"
@@ -142,13 +142,13 @@ const onInput = debounce(validateName, { debounceTime: 100 });
>
<template #trigger>
<template v-if="props.useTextTrigger">
<N8nButton data-test-id="data-store-add-column-trigger-button" type="tertiary">
{{ i18n.baseText('dataStore.addColumn.label') }}
<N8nButton data-test-id="data-table-add-column-trigger-button" type="tertiary">
{{ i18n.baseText('dataTable.addColumn.label') }}
</N8nButton>
</template>
<template v-else>
<N8nIconButton
data-test-id="data-store-add-column-trigger-button"
data-test-id="data-table-add-column-trigger-button"
text
icon="plus"
type="tertiary"
@@ -162,14 +162,14 @@ const onInput = debounce(validateName, { debounceTime: 100 });
>
<div class="popover-body">
<N8nInputLabel
:label="i18n.baseText('dataStore.addColumn.nameInput.label')"
:label="i18n.baseText('dataTable.addColumn.nameInput.label')"
:required="true"
:class="error ? '' : 'mb-s'"
>
<N8nInput
ref="nameInputRef"
v-model="columnName"
:placeholder="i18n.baseText('dataStore.addColumn.nameInput.placeholder')"
:placeholder="i18n.baseText('dataTable.addColumn.nameInput.placeholder')"
:maxlength="MAX_COLUMN_NAME_LENGTH"
@keyup.enter="onAddButtonClicked"
@input="onInput"
@@ -194,7 +194,7 @@ const onInput = debounce(validateName, { debounceTime: 100 });
</div>
</N8nInputLabel>
<N8nInputLabel
:label="i18n.baseText('dataStore.addColumn.typeInput.label')"
:label="i18n.baseText('dataTable.addColumn.typeInput.label')"
:required="true"
class="type-label"
>
@@ -217,14 +217,14 @@ const onInput = debounce(validateName, { debounceTime: 100 });
</N8nSelect>
</N8nInputLabel>
<N8nButton
data-test-id="data-store-add-column-submit-button"
data-test-id="data-table-add-column-submit-button"
type="primary"
class="mt-m"
size="large"
:disabled="!columnName || !columnType || !!error"
@click="onAddButtonClicked"
>
{{ i18n.baseText('dataStore.addColumn.label') }}
{{ i18n.baseText('dataTable.addColumn.label') }}
</N8nButton>
</div>
</div>
@@ -12,7 +12,7 @@ const props = defineProps<{
</script>
<template>
<N8nTooltip :content="i18n.baseText('dataStore.addRow.label')">
<N8nTooltip :content="i18n.baseText('dataTable.addRow.label')">
<N8nIconButton text type="tertiary" icon="plus" @click="props.params.onClick" />
</N8nTooltip>
</template>
@@ -4,7 +4,7 @@ import { vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import ColumnHeader, {
type HeaderParamsWithDelete,
} from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
} from '@/features/dataTable/components/dataGrid/ColumnHeader.vue';
// Mock N8nActionDropdown to make it easy to trigger item selection
vi.mock('@n8n/design-system', async (importOriginal) => {
@@ -57,12 +57,12 @@ const renderComponent = createComponentRenderer(ColumnHeader, {
describe('ColumnHeader', () => {
it('renders the column display name', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-column-header-text')).toHaveTextContent('My Column');
expect(getByTestId('data-table-column-header-text')).toHaveTextContent('My Column');
});
it('shows actions dropdown only on hover', async () => {
const { getByTestId } = renderComponent();
const wrapper = getByTestId('data-store-column-header');
const wrapper = getByTestId('data-table-column-header');
expect(wrapper).not.toBeNull();
const deleteButton = getByTestId('action-delete');
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { IHeaderParams, SortDirection } from 'ag-grid-community';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { useDataTableTypes } from '@/features/dataTable/composables/useDataTableTypes';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from '@n8n/i18n';
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
import { isAGGridCellType } from '@/features/dataTable/typeGuards';
import { N8nActionDropdown, N8nIcon, N8nIconButton } from '@n8n/design-system';
export type HeaderParamsWithDelete = IHeaderParams & {
@@ -16,7 +16,7 @@ const props = defineProps<{
params: HeaderParamsWithDelete;
}>();
const { getIconForType, mapToDataStoreColumnType } = useDataStoreTypes();
const { getIconForType, mapToDataTableColumnType } = useDataTableTypes();
const i18n = useI18n();
const isHovered = ref(false);
@@ -84,15 +84,15 @@ const typeIcon = computed(() => {
if (!isAGGridCellType(cellDataType)) {
return null;
}
return getIconForType(mapToDataStoreColumnType(cellDataType));
return getIconForType(mapToDataTableColumnType(cellDataType));
});
const columnActionItems = [
{
id: ItemAction.Delete,
label: i18n.baseText('dataStore.deleteColumn.confirm.title'),
label: i18n.baseText('dataTable.deleteColumn.confirm.title'),
icon: 'trash-2',
customClass: 'data-store-column-header-action-item',
customClass: 'data-table-column-header-action-item',
} as const,
];
@@ -150,15 +150,15 @@ onUnmounted(() => {
<template>
<div
:class="['ag-header-cell-label', 'data-store-column-header-wrapper', { sortable: isSortable }]"
data-test-id="data-store-column-header"
:class="['ag-header-cell-label', 'data-table-column-header-wrapper', { sortable: isSortable }]"
data-test-id="data-table-column-header"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@click="onHeaderClick"
>
<div class="data-store-column-header-icon-wrapper">
<div class="data-table-column-header-icon-wrapper">
<N8nIcon v-if="typeIcon" :icon="typeIcon" />
<span class="ag-header-cell-text" data-test-id="data-store-column-header-text">{{
<span class="ag-header-cell-text" data-test-id="data-table-column-header-text">{{
props.params.displayName
}}</span>
@@ -179,7 +179,7 @@ onUnmounted(() => {
<N8nActionDropdown
v-show="isMenuButtonVisible"
data-test-id="data-store-column-header-actions"
data-test-id="data-table-column-header-actions"
:items="columnActionItems"
:placement="'bottom-start'"
:activator-icon="'ellipsis'"
@@ -192,7 +192,7 @@ onUnmounted(() => {
<style lang="scss">
// TODO: neither scoped nor module works here. Is there a way to resolve this?
.data-store-column-header-wrapper {
.data-table-column-header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
@@ -204,12 +204,12 @@ onUnmounted(() => {
}
}
.data-store-column-header-action-item {
.data-table-column-header-action-item {
justify-content: flex-start;
gap: var(--spacing-xs);
}
.data-store-column-header-icon-wrapper {
.data-table-column-header-icon-wrapper {
flex: 1;
display: flex;
align-items: center;
@@ -217,7 +217,7 @@ onUnmounted(() => {
min-width: 0;
}
.data-store-column-header-icon-wrapper .n8n-icon {
.data-table-column-header-icon-wrapper .n8n-icon {
flex-shrink: 0;
}
@@ -1,8 +1,8 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreTable from '@/features/dataStore/components/dataGrid/DataStoreTable.vue';
import DataTableTable from '@/features/dataTable/components/dataGrid/DataTableTable.vue';
import { createPinia, setActivePinia } from 'pinia';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import type { DataStore } from '@/features/dataStore/datastore.types';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import type { DataTable } from '@/features/dataTable/dataTable.types';
// Mock ag-grid-vue3
interface MockComponentInstance {
@@ -55,17 +55,17 @@ vi.mock('ag-grid-community', () => ({
}));
// Mock the n8n theme
vi.mock('@/features/dataStore/components/dataGrid/n8nTheme', () => ({
vi.mock('@/features/dataTable/components/dataGrid/n8nTheme', () => ({
n8nTheme: 'n8n-theme',
}));
// Mock AddColumnPopover
vi.mock('@/features/dataStore/components/dataGrid/AddColumnPopover.vue', () => ({
vi.mock('@/features/dataTable/components/dataGrid/AddColumnPopover.vue', () => ({
default: {
name: 'AddColumnPopover',
template:
'<div data-test-id="add-column-popover"><button data-test-id="data-store-add-column-button" @click="$emit(\'add-column\', { column: { name: \'newColumn\', type: \'string\' } })">Add Column</button></div>',
props: ['dataStore'],
'<div data-test-id="add-column-popover"><button data-test-id="data-table-add-column-button" @click="$emit(\'add-column\', { column: { name: \'newColumn\', type: \'string\' } })">Add Column</button></div>',
props: ['dataTable'],
emits: ['add-column'],
},
}));
@@ -78,8 +78,8 @@ vi.mock('@/composables/useToast', () => ({
}),
}));
vi.mock('@/features/dataStore/composables/useDataStorePagination', () => ({
useDataStorePagination: () => ({
vi.mock('@/features/dataTable/composables/useDataTablePagination', () => ({
useDataTablePagination: () => ({
totalItems: 0,
setTotalItems: vi.fn(),
ensureItemOnPage: vi.fn(),
@@ -93,16 +93,16 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
useI18n: () => ({
baseText: (key: string) => {
const translations: Record<string, string> = {
'dataStore.addRow.label': 'Add Row',
'dataStore.addRow.disabled.tooltip': 'Add a column first',
'dataTable.addRow.label': 'Add Row',
'dataTable.addRow.disabled.tooltip': 'Add a column first',
};
return translations[key] || key;
},
}),
}));
vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
useDataStoreTypes: () => ({
vi.mock('@/features/dataTable/composables/useDataTableTypes', () => ({
useDataTableTypes: () => ({
mapToAGCellType: (type: string) => {
const typeMap: Record<string, string> = {
string: 'text',
@@ -115,9 +115,9 @@ vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
}),
}));
const mockDataStore: DataStore = {
id: 'test-datastore-1',
name: 'Test DataStore',
const mockDataTable: DataTable = {
id: 'test-dataTable-1',
name: 'Test DataTable',
projectId: 'project-1',
columns: [
{ id: 'col1', name: 'firstName', type: 'string', index: 1 },
@@ -129,19 +129,19 @@ const mockDataStore: DataStore = {
sizeBytes: 0,
};
describe('DataStoreTable', () => {
const renderComponent = createComponentRenderer(DataStoreTable, {
describe('DataTableTable', () => {
const renderComponent = createComponentRenderer(DataTableTable, {
props: {
dataStore: mockDataStore,
dataTable: mockDataTable,
},
});
let dataStoreStore: ReturnType<typeof useDataStoreStore>;
let dataTableStore: ReturnType<typeof useDataTableStore>;
beforeEach(() => {
setActivePinia(createPinia());
dataStoreStore = useDataStoreStore();
dataStoreStore.addDataStoreColumn = vi.fn().mockResolvedValue({
dataTableStore = useDataTableStore();
dataTableStore.addDataTableColumn = vi.fn().mockResolvedValue({
id: 'new-col',
name: 'newColumn',
type: 'string',
@@ -162,20 +162,20 @@ describe('DataStoreTable', () => {
it('should render pagination controls', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-content-pagination')).toBeInTheDocument();
expect(getByTestId('data-table-content-pagination')).toBeInTheDocument();
});
});
describe('Empty Data Store', () => {
it('should show grid for empty data store', () => {
const emptyDataStore: DataStore = {
...mockDataStore,
describe('Empty DataTable', () => {
it('should show grid for empty data table', () => {
const emptyDataTable: DataTable = {
...mockDataTable,
columns: [],
};
const { getByTestId } = renderComponent({
props: {
dataStore: emptyDataStore,
dataTable: emptyDataTable,
},
});
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue';
import type {
DataStore,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
DataTable,
DataTableColumnCreatePayload,
DataTableRow,
} from '@/features/dataTable/dataTable.types';
import { AgGridVue } from 'ag-grid-vue3';
import type { GetRowIdParams, GridReadyEvent } from 'ag-grid-community';
import {
@@ -30,14 +30,14 @@ import {
DateFilterModule,
EventApiModule,
} from 'ag-grid-community';
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
import { n8nTheme } from '@/features/dataTable/components/dataGrid/n8nTheme';
import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue';
import { DATA_STORE_HEADER_HEIGHT, DATA_STORE_ROW_HEIGHT } from '@/features/dataStore/constants';
import { useDataStorePagination } from '@/features/dataStore/composables/useDataStorePagination';
import { useDataStoreGridBase } from '@/features/dataStore/composables/useDataStoreGridBase';
import { useDataStoreSelection } from '@/features/dataStore/composables/useDataStoreSelection';
import { useDataStoreOperations } from '@/features/dataStore/composables/useDataStoreOperations';
import { useDataStoreColumnFilters } from '@/features/dataStore/composables/useDataStoreColumnFilters';
import { DATA_TABLE_HEADER_HEIGHT, DATA_TABLE_ROW_HEIGHT } from '@/features/dataTable/constants';
import { useDataTablePagination } from '@/features/dataTable/composables/useDataTablePagination';
import { useDataTableGridBase } from '@/features/dataTable/composables/useDataTableGridBase';
import { useDataTableSelection } from '@/features/dataTable/composables/useDataTableSelection';
import { useDataTableOperations } from '@/features/dataTable/composables/useDataTableOperations';
import { useDataTableColumnFilters } from '@/features/dataTable/composables/useDataTableColumnFilters';
import { useI18n } from '@n8n/i18n';
import { ElPagination } from 'element-plus';
@@ -66,7 +66,7 @@ ModuleRegistry.registerModules([
]);
type Props = {
dataStore: DataStore;
dataTable: DataTable;
};
const props = defineProps<Props>();
@@ -79,19 +79,19 @@ const gridContainerRef = useTemplateRef<HTMLDivElement>('gridContainerRef');
const i18n = useI18n();
const dataStoreGridBase = useDataStoreGridBase({
const dataTableGridBase = useDataTableGridBase({
gridContainerRef,
onDeleteColumn: onDeleteColumnFunction,
onAddRowClick: onAddRowClickFunction,
onAddColumn: onAddColumnFunction,
});
const rowData = ref<DataStoreRow[]>([]);
const rowData = ref<DataTableRow[]>([]);
const hasRecords = computed(() => rowData.value.length > 0);
const { initializeFilters, onFilterChanged, currentFilterJSON } = useDataStoreColumnFilters({
gridApi: dataStoreGridBase.gridApi,
colDefs: dataStoreGridBase.colDefs,
setGridData: dataStoreGridBase.setGridData,
const { initializeFilters, onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
gridApi: dataTableGridBase.gridApi,
colDefs: dataTableGridBase.colDefs,
setGridData: dataTableGridBase.setGridData,
});
const {
@@ -103,64 +103,64 @@ const {
setTotalItems,
setCurrentPage,
setPageSize,
} = useDataStorePagination({ onChange: fetchDataStoreRowsFunction });
} = useDataTablePagination({ onChange: fetchDataTableRowsFunction });
const selection = useDataStoreSelection({
gridApi: dataStoreGridBase.gridApi,
const selection = useDataTableSelection({
gridApi: dataTableGridBase.gridApi,
});
const dataStoreOperations = useDataStoreOperations({
colDefs: dataStoreGridBase.colDefs,
const dataTableOperations = useDataTableOperations({
colDefs: dataTableGridBase.colDefs,
rowData,
deleteGridColumn: dataStoreGridBase.deleteColumn,
setGridData: dataStoreGridBase.setGridData,
insertGridColumnAtIndex: dataStoreGridBase.insertColumnAtIndex,
dataStoreId: props.dataStore.id,
projectId: props.dataStore.projectId,
addGridColumn: dataStoreGridBase.addColumn,
moveGridColumn: dataStoreGridBase.moveColumn,
gridApi: dataStoreGridBase.gridApi,
deleteGridColumn: dataTableGridBase.deleteColumn,
setGridData: dataTableGridBase.setGridData,
insertGridColumnAtIndex: dataTableGridBase.insertColumnAtIndex,
dataTableId: props.dataTable.id,
projectId: props.dataTable.projectId,
addGridColumn: dataTableGridBase.addColumn,
moveGridColumn: dataTableGridBase.moveColumn,
gridApi: dataTableGridBase.gridApi,
totalItems,
setTotalItems,
ensureItemOnPage,
focusFirstEditableCell: dataStoreGridBase.focusFirstEditableCell,
focusFirstEditableCell: dataTableGridBase.focusFirstEditableCell,
toggleSave: emit.bind(null, 'toggleSave'),
currentPage,
pageSize,
currentSortBy: dataStoreGridBase.currentSortBy,
currentSortOrder: dataStoreGridBase.currentSortOrder,
currentSortBy: dataTableGridBase.currentSortBy,
currentSortOrder: dataTableGridBase.currentSortOrder,
handleClearSelection: selection.handleClearSelection,
selectedRowIds: selection.selectedRowIds,
handleCopyFocusedCell: dataStoreGridBase.handleCopyFocusedCell,
handleCopyFocusedCell: dataTableGridBase.handleCopyFocusedCell,
currentFilterJSON,
});
async function onDeleteColumnFunction(columnId: string) {
await dataStoreOperations.onDeleteColumn(columnId);
await dataTableOperations.onDeleteColumn(columnId);
}
async function onAddColumnFunction(column: DataStoreColumnCreatePayload) {
return await dataStoreOperations.onAddColumn(column);
async function onAddColumnFunction(column: DataTableColumnCreatePayload) {
return await dataTableOperations.onAddColumn(column);
}
async function onAddRowClickFunction() {
await dataStoreOperations.onAddRowClick();
await dataTableOperations.onAddRowClick();
}
async function fetchDataStoreRowsFunction() {
await dataStoreOperations.fetchDataStoreRows();
async function fetchDataTableRowsFunction() {
await dataTableOperations.fetchDataTableRows();
}
const initialize = async (params: GridReadyEvent) => {
dataStoreGridBase.onGridReady(params);
dataStoreGridBase.loadColumns(props.dataStore.columns);
await dataStoreOperations.fetchDataStoreRows();
dataTableGridBase.onGridReady(params);
dataTableGridBase.loadColumns(props.dataTable.columns);
await dataTableOperations.fetchDataTableRows();
initializeFilters();
};
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>`;
const customNoRowsOverlay = `<div class="no-rows-overlay ag-overlay-no-rows-center" data-test-id="data-table-no-rows-overlay">${i18n.baseText('dataTable.noRows')}</div>`;
watch([dataStoreGridBase.currentSortBy, dataStoreGridBase.currentSortOrder], async () => {
watch([dataTableGridBase.currentSortBy, dataTableGridBase.currentSortOrder], async () => {
await setCurrentPage(1);
});
@@ -169,8 +169,8 @@ watch(currentFilterJSON, async () => {
});
defineExpose({
addRow: dataStoreOperations.onAddRowClick,
addColumn: dataStoreOperations.onAddColumn,
addRow: dataTableOperations.onAddRowClick,
addColumn: dataTableOperations.onAddColumn,
});
</script>
@@ -179,17 +179,17 @@ defineExpose({
<div
ref="gridContainerRef"
:class="[$style['grid-container'], { [$style['has-records']]: hasRecords }]"
data-test-id="data-store-grid"
data-test-id="data-table-grid"
>
<AgGridVue
style="width: 100%"
:dom-layout="'autoHeight'"
:row-height="DATA_STORE_ROW_HEIGHT"
:header-height="DATA_STORE_HEADER_HEIGHT"
:row-height="DATA_TABLE_ROW_HEIGHT"
:header-height="DATA_TABLE_HEADER_HEIGHT"
:animate-rows="false"
:theme="n8nTheme"
:suppress-drag-leave-hides-columns="true"
:loading="dataStoreOperations.contentLoading.value"
:loading="dataTableOperations.contentLoading.value"
:row-selection="selection.rowSelection"
:get-row-id="(params: GetRowIdParams) => String(params.data.id)"
:stop-editing-when-cells-lose-focus="true"
@@ -197,22 +197,22 @@ defineExpose({
:suppress-multi-sort="true"
:overlay-no-rows-template="customNoRowsOverlay"
@grid-ready="initialize"
@cell-value-changed="dataStoreOperations.onCellValueChanged"
@column-moved="dataStoreOperations.onColumnMoved"
@cell-clicked="dataStoreGridBase.onCellClicked"
@cell-editing-started="dataStoreGridBase.onCellEditingStarted"
@cell-editing-stopped="dataStoreGridBase.onCellEditingStopped"
@column-header-clicked="dataStoreGridBase.resetLastFocusedCell"
@cell-value-changed="dataTableOperations.onCellValueChanged"
@column-moved="dataTableOperations.onColumnMoved"
@cell-clicked="dataTableGridBase.onCellClicked"
@cell-editing-started="dataTableGridBase.onCellEditingStarted"
@cell-editing-stopped="dataTableGridBase.onCellEditingStopped"
@column-header-clicked="dataTableGridBase.resetLastFocusedCell"
@selection-changed="selection.onSelectionChanged"
@sort-changed="dataStoreGridBase.onSortChanged"
@cell-key-down="dataStoreOperations.onCellKeyDown"
@sort-changed="dataTableGridBase.onSortChanged"
@cell-key-down="dataTableOperations.onCellKeyDown"
@filter-changed="onFilterChanged"
/>
<div :class="$style.footer">
<ElPagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
data-test-id="data-store-content-pagination"
data-test-id="data-table-content-pagination"
background
:total="totalItems"
:page-sizes="pageSizeOptions"
@@ -224,7 +224,7 @@ defineExpose({
</div>
<SelectedItemsInfo
:selected-count="selection.selectedCount.value"
@delete-selected="dataStoreOperations.handleDeleteSelected"
@delete-selected="dataTableOperations.handleDeleteSelected"
@clear-selection="selection.handleClearSelection"
/>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import type { ICellEditorParams } from 'ag-grid-community';
import { useDatePickerCommon } from '@/features/dataStore/composables/useDatePickerCommon';
import { useDatePickerCommon } from '@/features/dataTable/composables/useDatePickerCommon';
import { ElDatePicker } from 'element-plus';
const props = defineProps<{
@@ -45,9 +45,9 @@ defineExpose({
</script>
<template>
<div ref="wrapperRef" class="datastore-datepicker-wrapper">
<div ref="wrapperRef" class="data-table-datepicker-wrapper">
<ElDatePicker
id="datastore-datepicker"
id="data-table-datepicker"
ref="pickerRef"
v-model="dateValueComputed"
type="datetime"
@@ -55,7 +55,7 @@ defineExpose({
:clearable="true"
:editable="true"
:teleported="false"
popper-class="ag-custom-component-popup datastore-datepicker-popper"
popper-class="ag-custom-component-popup data-table-datepicker-popper"
placeholder="YYYY-MM-DD (HH:mm:ss)"
size="small"
@change="onDateChange"
@@ -66,7 +66,7 @@ defineExpose({
</template>
<style lang="scss">
.datastore-datepicker-wrapper {
.data-table-datepicker-wrapper {
border-radius: var(--border-radius-base);
.el-input__prefix {
@@ -91,7 +91,7 @@ defineExpose({
}
}
.datastore-datepicker-popper {
.data-table-datepicker-popper {
// Hide the date input in the popper
.el-date-picker__time-header .el-date-picker__editor-wrap:first-child {
display: none;
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import type { IDateParams } from 'ag-grid-community';
import { useDatePickerCommon } from '@/features/dataStore/composables/useDatePickerCommon';
import { useDatePickerCommon } from '@/features/dataTable/composables/useDatePickerCommon';
import { ElDatePicker } from 'element-plus';
const props = defineProps<{
@@ -45,7 +45,7 @@ defineExpose({
</script>
<template>
<div ref="wrapperRef" class="datastore-date-filter-wrapper">
<div ref="wrapperRef" class="data-table-date-filter-wrapper">
<ElDatePicker
ref="pickerRef"
v-model="dateValueComputed"
@@ -54,7 +54,7 @@ defineExpose({
:editable="true"
:teleported="true"
placement="bottom"
popper-class="ag-custom-component-popup datastore-date-filter-popper"
popper-class="ag-custom-component-popup data-table-date-filter-popper"
placeholder="YYYY-MM-DD (HH:mm:ss)"
size="small"
@change="onDateChange"
@@ -65,7 +65,7 @@ defineExpose({
</template>
<style lang="scss">
.datastore-date-filter-wrapper {
.data-table-date-filter-wrapper {
border: var(--ag-picker-button-border);
border-radius: var(--border-radius-base);
@@ -91,7 +91,7 @@ defineExpose({
}
}
.datastore-date-filter-popper {
.data-table-date-filter-popper {
.el-date-picker__time-header .el-date-picker__editor-wrap:first-child {
display: none;
}
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref, type Ref } from 'vue';
import { useDataStoreColumnFilters } from '../useDataStoreColumnFilters';
import { useDataTableColumnFilters } from '../useDataTableColumnFilters';
import type { ColDef, GridApi } from 'ag-grid-community';
describe('useDataStoreColumnFilters', () => {
describe('useDataTableColumnFilters', () => {
let mockGridApi: GridApi;
let mockSetGridData: ReturnType<typeof vi.fn>;
let colDefs: Ref<ColDef[]>;
@@ -26,7 +26,7 @@ describe('useDataStoreColumnFilters', () => {
describe('initializeFilters', () => {
it('should disable filters for special columns', () => {
const gridApi = ref(mockGridApi);
const { initializeFilters } = useDataStoreColumnFilters({
const { initializeFilters } = useDataTableColumnFilters({
gridApi,
colDefs,
setGridData: mockSetGridData,
@@ -52,7 +52,7 @@ describe('useDataStoreColumnFilters', () => {
it('should process text filters', () => {
const gridApi = ref(mockGridApi);
const { onFilterChanged, currentFilterJSON } = useDataStoreColumnFilters({
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
gridApi,
colDefs,
setGridData: mockSetGridData,
@@ -84,7 +84,7 @@ describe('useDataStoreColumnFilters', () => {
it('should return undefined when no filters', () => {
const gridApi = ref(mockGridApi);
const { onFilterChanged, currentFilterJSON } = useDataStoreColumnFilters({
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
gridApi,
colDefs,
setGridData: mockSetGridData,
@@ -101,7 +101,7 @@ describe('useDataStoreColumnFilters', () => {
describe('hasActiveFilters', () => {
it('should be false when no filters are active', () => {
const gridApi = ref(mockGridApi);
const { hasActiveFilters } = useDataStoreColumnFilters({
const { hasActiveFilters } = useDataTableColumnFilters({
gridApi,
colDefs,
setGridData: mockSetGridData,
@@ -112,7 +112,7 @@ describe('useDataStoreColumnFilters', () => {
it('should be true when filters are active', () => {
const gridApi = ref(mockGridApi);
const { onFilterChanged, hasActiveFilters } = useDataStoreColumnFilters({
const { onFilterChanged, hasActiveFilters } = useDataTableColumnFilters({
gridApi,
colDefs,
setGridData: mockSetGridData,
@@ -1,11 +1,11 @@
import { computed, ref, type Ref } from 'vue';
import type { ColDef, GridApi } from 'ag-grid-community';
import type { DataStoreRow } from '@/features/dataStore/datastore.types';
import type { DataTableRow } from '@/features/dataTable/dataTable.types';
import type {
BackendFilter,
BackendFilterRecord,
FilterModel,
} from '../types/dataStoreFilters.types';
} from '../types/dataTableFilters.types';
import { GRID_FILTER_CONFIG, isSpecialColumn } from '../utils/filterMappings';
import {
processTextFilter,
@@ -13,17 +13,20 @@ import {
processDateFilter,
} from '../utils/filterProcessors';
export type UseDataStoreColumnFiltersParams = {
export type UseDataTableColumnFiltersParams = {
gridApi: Ref<GridApi>;
colDefs: Ref<ColDef[]>;
setGridData: (params: { rowData?: DataStoreRow[]; colDefs?: ColDef[] }) => void;
setGridData: (params: {
rowData?: DataTableRow[];
colDefs?: ColDef[];
}) => void;
};
export const useDataStoreColumnFilters = ({
export const useDataTableColumnFilters = ({
gridApi,
colDefs,
setGridData,
}: UseDataStoreColumnFiltersParams) => {
}: UseDataTableColumnFiltersParams) => {
const currentFilterJSON = ref<string | undefined>(undefined);
const initializeFilters = () => {
@@ -13,24 +13,24 @@ import type {
} from 'ag-grid-community';
import type {
AddColumnResponse,
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
DataTableColumn,
DataTableColumnCreatePayload,
DataTableRow,
} from '@/features/dataTable/dataTable.types';
import {
ADD_ROW_ROW_ID,
DATA_STORE_ID_COLUMN_WIDTH,
DATA_TABLE_ID_COLUMN_WIDTH,
DEFAULT_COLUMN_WIDTH,
DEFAULT_ID_COLUMN_NAME,
} from '@/features/dataStore/constants';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
import ElDatePickerCellEditor from '@/features/dataStore/components/dataGrid/ElDatePickerCellEditor.vue';
import ElDatePickerFilter from '@/features/dataStore/components/dataGrid/ElDatePickerFilter.vue';
} from '@/features/dataTable/constants';
import { useDataTableTypes } from '@/features/dataTable/composables/useDataTableTypes';
import ColumnHeader from '@/features/dataTable/components/dataGrid/ColumnHeader.vue';
import ElDatePickerCellEditor from '@/features/dataTable/components/dataGrid/ElDatePickerCellEditor.vue';
import ElDatePickerFilter from '@/features/dataTable/components/dataGrid/ElDatePickerFilter.vue';
import orderBy from 'lodash/orderBy';
import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue';
import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.vue';
import { reorderItem } from '@/features/dataStore/utils';
import AddColumnButton from '@/features/dataTable/components/dataGrid/AddColumnButton.vue';
import AddRowButton from '@/features/dataTable/components/dataGrid/AddRowButton.vue';
import { reorderItem } from '@/features/dataTable/utils';
import { useClipboard } from '@/composables/useClipboard';
import { onClickOutside } from '@vueuse/core';
import {
@@ -45,10 +45,10 @@ import {
getDateColumnFilterOptions,
getNumberColumnFilterOptions,
getBooleanColumnFilterOptions,
} from '@/features/dataStore/utils/columnUtils';
} from '@/features/dataTable/utils/columnUtils';
import { useI18n } from '@n8n/i18n';
export const useDataStoreGridBase = ({
export const useDataTableGridBase = ({
gridContainerRef,
onDeleteColumn,
onAddRowClick,
@@ -57,12 +57,12 @@ export const useDataStoreGridBase = ({
gridContainerRef: Ref<HTMLElement | null>;
onDeleteColumn: (columnId: string) => void;
onAddRowClick: () => void;
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<AddColumnResponse>;
onAddColumn: (column: DataTableColumnCreatePayload) => Promise<AddColumnResponse>;
}) => {
const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]);
const isTextEditorOpen = ref(false);
const { mapToAGCellType } = useDataStoreTypes();
const { mapToAGCellType } = useDataTableTypes();
const { copy: copyToClipboard } = useClipboard({ onPaste: onClipboardPaste });
const i18n = useI18n();
const currentSortBy = ref<string>(DEFAULT_ID_COLUMN_NAME);
@@ -92,7 +92,7 @@ export const useDataStoreGridBase = ({
rowData,
}: {
colDefs?: ColDef[];
rowData?: DataStoreRow[];
rowData?: DataTableRow[];
}) => {
if (colDefs) {
initializedGridApi.value.setGridOption('columnDefs', colDefs);
@@ -132,7 +132,7 @@ export const useDataStoreGridBase = ({
});
};
const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {}) => {
const createColumnDef = (col: DataTableColumn, extraProps: Partial<ColDef> = {}) => {
const columnDef: ColDef = {
colId: col.id,
field: col.name,
@@ -142,7 +142,10 @@ export const useDataStoreGridBase = ({
resizable: true,
lockPinned: true,
headerComponent: ColumnHeader,
headerComponentParams: { onDelete: onDeleteColumn, allowMenuActions: true },
headerComponentParams: {
onDelete: onDeleteColumn,
allowMenuActions: true,
},
cellEditorPopup: false,
cellDataType: mapToAGCellType(col.type),
cellClass: getCellClass,
@@ -187,7 +190,7 @@ export const useDataStoreGridBase = ({
};
};
const onCellEditingStarted = (params: CellEditingStartedEvent<DataStoreRow>) => {
const onCellEditingStarted = (params: CellEditingStartedEvent<DataTableRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = true;
} else {
@@ -195,13 +198,13 @@ export const useDataStoreGridBase = ({
}
};
const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) => {
const onCellEditingStopped = (params: CellEditingStoppedEvent<DataTableRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = false;
}
};
const getColumnDefinitions = (dataStoreColumns: DataStoreColumn[]) => {
const getColumnDefinitions = (dataTableColumns: DataTableColumn[]) => {
const systemDateColumnOptions: Partial<ColDef> = {
editable: false,
suppressMovable: true,
@@ -215,8 +218,8 @@ export const useDataStoreGridBase = ({
width: DEFAULT_COLUMN_WIDTH,
};
return [
// Always add the ID column, it's not returned by the back-end but all data stores have it
// We use it as a placeholder for new datastores
// Always add the ID column, it's not returned by the back-end but all data tables have it
// We use it as a placeholder for new data tables
createColumnDef(
{
index: 0,
@@ -230,8 +233,8 @@ export const useDataStoreGridBase = ({
filter: false,
suppressMovable: true,
lockPosition: true,
minWidth: DATA_STORE_ID_COLUMN_WIDTH,
maxWidth: DATA_STORE_ID_COLUMN_WIDTH,
minWidth: DATA_TABLE_ID_COLUMN_WIDTH,
maxWidth: DATA_TABLE_ID_COLUMN_WIDTH,
resizable: false,
headerClass: 'system-column',
headerComponentParams: {
@@ -252,10 +255,10 @@ export const useDataStoreGridBase = ({
},
),
// Append other columns
...orderBy(dataStoreColumns, 'index').map((col) => createColumnDef(col)),
...orderBy(dataTableColumns, 'index').map((col) => createColumnDef(col)),
createColumnDef(
{
index: dataStoreColumns.length + 1,
index: dataTableColumns.length + 1,
id: 'createdAt',
name: 'createdAt',
type: 'date',
@@ -264,7 +267,7 @@ export const useDataStoreGridBase = ({
),
createColumnDef(
{
index: dataStoreColumns.length + 2,
index: dataTableColumns.length + 2,
id: 'updatedAt',
name: 'updatedAt',
type: 'date',
@@ -273,7 +276,7 @@ export const useDataStoreGridBase = ({
),
createColumnDef(
{
index: dataStoreColumns.length + 3,
index: dataTableColumns.length + 3,
id: 'add-column',
name: 'Add Column',
type: 'string',
@@ -292,8 +295,8 @@ export const useDataStoreGridBase = ({
];
};
const loadColumns = (dataStoreColumns: DataStoreColumn[]) => {
colDefs.value = getColumnDefinitions(dataStoreColumns);
const loadColumns = (dataTableColumns: DataTableColumn[]) => {
colDefs.value = getColumnDefinitions(dataTableColumns);
setGridData({ colDefs: colDefs.value });
};
@@ -307,7 +310,7 @@ export const useDataStoreGridBase = ({
setGridData({ colDefs: colDefs.value });
};
const addColumn = (column: DataStoreColumn) => {
const addColumn = (column: DataTableColumn) => {
colDefs.value = [
...colDefs.value.slice(0, -1),
createColumnDef(column),
@@ -329,7 +332,7 @@ export const useDataStoreGridBase = ({
colDefs.value = [colDefs.value[0], ...reorderedMiddle, colDefs.value[colDefs.value.length - 1]];
};
const handleCopyFocusedCell = async (params: CellKeyDownEvent<DataStoreRow>) => {
const handleCopyFocusedCell = async (params: CellKeyDownEvent<DataTableRow>) => {
const focused = params.api.getFocusedCell();
if (!focused) {
return;
@@ -370,13 +373,17 @@ export const useDataStoreGridBase = ({
}
}
const onCellClicked = (params: CellClickedEvent<DataStoreRow>) => {
const onCellClicked = (params: CellClickedEvent<DataTableRow>) => {
const clickedCellColumn = params.column.getColId();
const clickedCellRow = params.rowIndex;
if (
clickedCellRow === null ||
params.api.isEditing({ rowIndex: clickedCellRow, column: params.column, rowPinned: null })
params.api.isEditing({
rowIndex: clickedCellRow,
column: params.column,
rowPinned: null,
})
)
return;
@@ -1,32 +1,32 @@
import {
useDataStoreOperations,
type UseDataStoreOperationsParams,
} from '@/features/dataStore/composables/useDataStoreOperations';
useDataTableOperations,
type UseDataTableOperationsParams,
} from '@/features/dataTable/composables/useDataTableOperations';
import { ref } from 'vue';
import type { GridApi } from 'ag-grid-community';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
vi.mock('@/features/dataStore/dataStore.store', () => ({
useDataStoreStore: vi.fn(() => ({})),
vi.mock('@/features/dataTable/dataTable.store', () => ({
useDataTableStore: vi.fn(() => ({})),
}));
describe('useDataStoreOperations', () => {
let params: UseDataStoreOperationsParams;
let dataStoreStore: ReturnType<typeof useDataStoreStore>;
describe('useDataTableOperations', () => {
let params: UseDataTableOperationsParams;
let dataTableStore: ReturnType<typeof useDataTableStore>;
beforeEach(() => {
setActivePinia(createTestingPinia());
dataStoreStore = {
addDataStoreColumn: vi.fn(),
deleteDataStoreColumn: vi.fn(),
moveDataStoreColumn: vi.fn(),
dataTableStore = {
addDataTableColumn: vi.fn(),
deleteDataTableColumn: vi.fn(),
moveDataTableColumn: vi.fn(),
deleteRows: vi.fn(),
insertEmptyRow: vi.fn(),
} as unknown as ReturnType<typeof useDataStoreStore>;
} as unknown as ReturnType<typeof useDataTableStore>;
vi.mocked(useDataStoreStore).mockReturnValue(dataStoreStore);
vi.mocked(useDataTableStore).mockReturnValue(dataTableStore);
params = {
colDefs: ref([]),
@@ -36,7 +36,7 @@ describe('useDataStoreOperations', () => {
setGridData: vi.fn(),
insertGridColumnAtIndex: vi.fn(),
moveGridColumn: vi.fn(),
dataStoreId: 'test',
dataTableId: 'test',
projectId: 'test',
gridApi: ref(null as unknown as GridApi),
totalItems: ref(0),
@@ -60,23 +60,23 @@ describe('useDataStoreOperations', () => {
describe('onAddColumn', () => {
it('should raise error when column is not added', async () => {
vi.mocked(useDataStoreStore).mockReturnValue({
...dataStoreStore,
addDataStoreColumn: vi.fn().mockRejectedValue(new Error('test')),
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
addDataTableColumn: vi.fn().mockRejectedValue(new Error('test')),
});
const { onAddColumn } = useDataStoreOperations(params);
const { onAddColumn } = useDataTableOperations(params);
const result = await onAddColumn({ name: 'test', type: 'string' });
expect(result.success).toBe(false);
});
it('should add column when column is added', async () => {
const returnedColumn = { name: 'test', type: 'string' } as const;
vi.mocked(useDataStoreStore).mockReturnValue({
...dataStoreStore,
addDataStoreColumn: vi.fn().mockResolvedValue(returnedColumn),
vi.mocked(useDataTableStore).mockReturnValue({
...dataTableStore,
addDataTableColumn: vi.fn().mockResolvedValue(returnedColumn),
});
const rowData = ref([{ id: 1 }]);
const { onAddColumn } = useDataStoreOperations({ ...params, rowData });
const { onAddColumn } = useDataTableOperations({ ...params, rowData });
const result = await onAddColumn({ name: returnedColumn.name, type: returnedColumn.type });
expect(result.success).toBe(true);
expect(params.setGridData).toHaveBeenCalledWith({ rowData: [{ id: 1, test: null }] });
@@ -3,10 +3,10 @@ import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import type {
AddColumnResponse,
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
DataTableColumn,
DataTableColumnCreatePayload,
DataTableRow,
} from '@/features/dataTable/dataTable.types';
import { ref, type Ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import type {
@@ -16,21 +16,24 @@ import type {
ColumnMovedEvent,
GridApi,
} from 'ag-grid-community';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { MODAL_CONFIRM } from '@/constants';
import { isDataStoreValue, isAGGridCellType } from '@/features/dataStore/typeGuards';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { areValuesEqual } from '@/features/dataStore/utils/typeUtils';
import { isDataTableValue, isAGGridCellType } from '@/features/dataTable/typeGuards';
import { useDataTableTypes } from '@/features/dataTable/composables/useDataTableTypes';
import { areValuesEqual } from '@/features/dataTable/utils/typeUtils';
export type UseDataStoreOperationsParams = {
export type UseDataTableOperationsParams = {
colDefs: Ref<ColDef[]>;
rowData: Ref<DataStoreRow[]>;
rowData: Ref<DataTableRow[]>;
deleteGridColumn: (columnId: string) => void;
addGridColumn: (column: DataStoreColumn) => void;
setGridData: (params: { rowData?: DataStoreRow[]; colDefs?: ColDef[] }) => void;
addGridColumn: (column: DataTableColumn) => void;
setGridData: (params: {
rowData?: DataTableRow[];
colDefs?: ColDef[];
}) => void;
insertGridColumnAtIndex: (column: ColDef, index: number) => void;
moveGridColumn: (oldIndex: number, newIndex: number) => void;
dataStoreId: string;
dataTableId: string;
projectId: string;
gridApi: Ref<GridApi>;
totalItems: Ref<number>;
@@ -45,10 +48,10 @@ export type UseDataStoreOperationsParams = {
currentFilterJSON?: Ref<string | undefined>;
handleClearSelection: () => void;
selectedRowIds: Ref<Set<number>>;
handleCopyFocusedCell: (params: CellKeyDownEvent<DataStoreRow>) => Promise<void>;
handleCopyFocusedCell: (params: CellKeyDownEvent<DataTableRow>) => Promise<void>;
};
export const useDataStoreOperations = ({
export const useDataTableOperations = ({
colDefs,
rowData,
deleteGridColumn,
@@ -56,7 +59,7 @@ export const useDataStoreOperations = ({
setGridData,
insertGridColumnAtIndex,
moveGridColumn,
dataStoreId,
dataTableId,
projectId,
gridApi,
totalItems,
@@ -72,24 +75,24 @@ export const useDataStoreOperations = ({
handleClearSelection,
selectedRowIds,
handleCopyFocusedCell,
}: UseDataStoreOperationsParams) => {
}: UseDataTableOperationsParams) => {
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
const contentLoading = ref(false);
const telemetry = useTelemetry();
const dataStoreTypes = useDataStoreTypes();
const dataTableTypes = useDataTableTypes();
async function onDeleteColumn(columnId: string) {
const columnToDelete = colDefs.value.find((col) => col.colId === columnId);
if (!columnToDelete) return;
const promptResponse = await message.confirm(
i18n.baseText('dataStore.deleteColumn.confirm.message', {
i18n.baseText('dataTable.deleteColumn.confirm.message', {
interpolate: { name: columnToDelete.headerName ?? '' },
}),
i18n.baseText('dataStore.deleteColumn.confirm.title'),
i18n.baseText('dataTable.deleteColumn.confirm.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
@@ -109,23 +112,23 @@ export const useDataStoreOperations = ({
});
setGridData({ rowData: rowData.value });
try {
await dataStoreStore.deleteDataStoreColumn(dataStoreId, projectId, columnId);
await dataTableStore.deleteDataTableColumn(dataTableId, projectId, columnId);
telemetry.track('User deleted data table column', {
column_id: columnId,
column_type: columnToDelete.cellDataType,
data_table_id: dataStoreId,
data_table_id: dataTableId,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.deleteColumn.error'));
toast.showError(error, i18n.baseText('dataTable.deleteColumn.error'));
insertGridColumnAtIndex(columnToDelete, columnToDeleteIndex);
rowData.value = rowDataOldValue;
setGridData({ rowData: rowData.value });
}
}
async function onAddColumn(column: DataStoreColumnCreatePayload): Promise<AddColumnResponse> {
async function onAddColumn(column: DataTableColumnCreatePayload): Promise<AddColumnResponse> {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(dataStoreId, projectId, column);
const newColumn = await dataTableStore.addDataTableColumn(dataTableId, projectId, column);
addGridColumn(newColumn);
rowData.value = rowData.value.map((row) => {
return { ...row, [newColumn.name]: null };
@@ -134,11 +137,11 @@ export const useDataStoreOperations = ({
telemetry.track('User added data table column', {
column_id: newColumn.id,
column_type: newColumn.type,
data_table_id: dataStoreId,
data_table_id: dataTableId,
});
return { success: true, httpStatus: 200 };
} catch (error) {
const addColumnError = dataStoreTypes.getAddColumnError(error);
const addColumnError = dataTableTypes.getAddColumnError(error);
return {
success: false,
httpStatus: addColumnError.httpStatus,
@@ -160,15 +163,15 @@ export const useDataStoreOperations = ({
const oldIndex = colDefs.value.findIndex((col) => col.colId === moveEvent.column!.getColId());
const newIndex = moveEvent.toIndex - 2; // selection and id columns are included here
try {
await dataStoreStore.moveDataStoreColumn(
dataStoreId,
await dataTableStore.moveDataTableColumn(
dataTableId,
projectId,
moveEvent.column.getColId(),
newIndex,
);
moveGridColumn(oldIndex, newIndex);
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.moveColumn.error'));
toast.showError(error, i18n.baseText('dataTable.moveColumn.error'));
gridApi.value.moveColumnByIndex(moveEvent.toIndex, oldIndex + 1);
}
};
@@ -179,24 +182,24 @@ export const useDataStoreOperations = ({
contentLoading.value = true;
toggleSave(true);
const insertedRow = await dataStoreStore.insertEmptyRow(dataStoreId, projectId);
const newRow: DataStoreRow = insertedRow;
const insertedRow = await dataTableStore.insertEmptyRow(dataTableId, projectId);
const newRow: DataTableRow = insertedRow;
rowData.value.push(newRow);
setTotalItems(totalItems.value + 1);
setGridData({ rowData: rowData.value });
focusFirstEditableCell(newRow.id as number);
telemetry.track('User added row to data table', {
data_table_id: dataStoreId,
data_table_id: dataTableId,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addRow.error'));
toast.showError(error, i18n.baseText('dataTable.addRow.error'));
} finally {
toggleSave(false);
contentLoading.value = false;
}
}
const onCellValueChanged = async (params: CellValueChangedEvent<DataStoreRow>) => {
const onCellValueChanged = async (params: CellValueChangedEvent<DataTableRow>) => {
const { data, api, oldValue, colDef } = params;
const fieldName = String(colDef.field ?? '');
if (!fieldName) return;
@@ -204,7 +207,7 @@ export const useDataStoreOperations = ({
const value = params.data[fieldName];
const cellType = isAGGridCellType(colDef.cellDataType)
? dataStoreTypes.mapToDataStoreColumnType(colDef.cellDataType)
? dataTableTypes.mapToDataTableColumnType(colDef.cellDataType)
: undefined;
if (value === undefined || areValuesEqual(oldValue, value, cellType)) {
@@ -218,33 +221,36 @@ export const useDataStoreOperations = ({
try {
toggleSave(true);
await dataStoreStore.updateRow(dataStoreId, projectId, id, {
await dataTableStore.updateRow(dataTableId, projectId, id, {
[fieldName]: value,
});
telemetry.track('User edited data table content', {
data_table_id: dataStoreId,
data_table_id: dataTableId,
column_id: colDef.colId,
column_type: colDef.cellDataType,
});
} catch (error) {
// Revert cell to original value if the update fails
const validOldValue = isDataStoreValue(oldValue) ? oldValue : null;
const revertedData: DataStoreRow = { ...data, [fieldName]: validOldValue };
const validOldValue = isDataTableValue(oldValue) ? oldValue : null;
const revertedData: DataTableRow = {
...data,
[fieldName]: validOldValue,
};
api.applyTransaction({
update: [revertedData],
});
toast.showError(error, i18n.baseText('dataStore.updateRow.error'));
toast.showError(error, i18n.baseText('dataTable.updateRow.error'));
} finally {
toggleSave(false);
}
};
async function fetchDataStoreRows() {
async function fetchDataTableRows() {
try {
contentLoading.value = true;
const fetchedRows = await dataStoreStore.fetchDataStoreContent(
dataStoreId,
const fetchedRows = await dataTableStore.fetchDataTableContent(
dataTableId,
projectId,
currentPage.value,
pageSize.value,
@@ -256,7 +262,7 @@ export const useDataStoreOperations = ({
setGridData({ rowData: rowData.value });
handleClearSelection();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
toast.showError(error, i18n.baseText('dataTable.fetchContent.error'));
} finally {
contentLoading.value = false;
}
@@ -266,11 +272,11 @@ export const useDataStoreOperations = ({
if (selectedRowIds.value.size === 0) return;
const confirmResponse = await message.confirm(
i18n.baseText('dataStore.deleteRows.confirmation', {
i18n.baseText('dataTable.deleteRows.confirmation', {
adjustToNumber: selectedRowIds.value.size,
interpolate: { count: selectedRowIds.value.size },
}),
i18n.baseText('dataStore.deleteRows.title'),
i18n.baseText('dataTable.deleteRows.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
@@ -284,21 +290,21 @@ export const useDataStoreOperations = ({
try {
toggleSave(true);
const idsToDelete = Array.from(selectedRowIds.value);
await dataStoreStore.deleteRows(dataStoreId, projectId, idsToDelete);
await fetchDataStoreRows();
await dataTableStore.deleteRows(dataTableId, projectId, idsToDelete);
await fetchDataTableRows();
telemetry.track('User deleted rows in data table', {
data_table_id: dataStoreId,
data_table_id: dataTableId,
deleted_row_count: idsToDelete.length,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.deleteRows.error'));
toast.showError(error, i18n.baseText('dataTable.deleteRows.error'));
} finally {
toggleSave(false);
}
};
const onCellKeyDown = async (params: CellKeyDownEvent<DataStoreRow>) => {
const onCellKeyDown = async (params: CellKeyDownEvent<DataTableRow>) => {
const event = params.event as KeyboardEvent;
const target = event.target as HTMLElement;
@@ -335,7 +341,7 @@ export const useDataStoreOperations = ({
onAddRowClick,
contentLoading,
onCellValueChanged,
fetchDataStoreRows,
fetchDataTableRows,
handleDeleteSelected,
onCellKeyDown,
};
@@ -1,14 +1,14 @@
import { ref } from 'vue';
export type PageSize = 10 | 20 | 50;
export type UseDataStorePaginationOptions = {
export type UseDataTablePaginationOptions = {
initialPage?: number;
initialPageSize?: PageSize;
pageSizeOptions?: PageSize[];
onChange?: (page: number, pageSize: number) => Promise<void> | void;
};
export const useDataStorePagination = (options: UseDataStorePaginationOptions = {}) => {
export const useDataTablePagination = (options: UseDataTablePaginationOptions = {}) => {
const currentPage = ref<number>(options.initialPage ?? 1);
const pageSize = ref<PageSize>(options.initialPageSize ?? 20);
const totalItems = ref<number>(0);
@@ -1,6 +1,6 @@
import { ref } from 'vue';
import type { GridApi, IRowNode } from 'ag-grid-community';
import { useDataStoreSelection } from './useDataStoreSelection';
import { useDataTableSelection } from './useDataTableSelection';
const createMockGridApi = () =>
({
@@ -8,7 +8,7 @@ const createMockGridApi = () =>
deselectAll: vi.fn(),
}) as unknown as GridApi;
describe('useDataStoreSelection', () => {
describe('useDataTableSelection', () => {
let mockGridApi: GridApi;
beforeEach(() => {
@@ -22,7 +22,7 @@ describe('useDataStoreSelection', () => {
describe('onSelectionChanged', () => {
it('should update selectedRowIds with numeric IDs from selected nodes', () => {
const gridApi = ref(mockGridApi);
const { selectedRowIds, onSelectionChanged } = useDataStoreSelection({ gridApi });
const { selectedRowIds, onSelectionChanged } = useDataTableSelection({ gridApi });
const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }, { data: { id: 3 } }];
@@ -35,7 +35,7 @@ describe('useDataStoreSelection', () => {
it('should filter out non-numeric IDs', () => {
const gridApi = ref(mockGridApi);
const { selectedRowIds, onSelectionChanged } = useDataStoreSelection({ gridApi });
const { selectedRowIds, onSelectionChanged } = useDataTableSelection({ gridApi });
const mockSelectedNodes = [
{ data: { id: 1 } },
@@ -54,7 +54,7 @@ describe('useDataStoreSelection', () => {
it('should update selectedCount reactively', () => {
const gridApi = ref(mockGridApi);
const { selectedCount, onSelectionChanged } = useDataStoreSelection({ gridApi });
const { selectedCount, onSelectionChanged } = useDataTableSelection({ gridApi });
const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }, { data: { id: 3 } }];
@@ -69,7 +69,7 @@ describe('useDataStoreSelection', () => {
describe('handleClearSelection', () => {
it('should clear selectedRowIds and call deselectAll on grid', () => {
const gridApi = ref(mockGridApi);
const { selectedRowIds, handleClearSelection, onSelectionChanged } = useDataStoreSelection({
const { selectedRowIds, handleClearSelection, onSelectionChanged } = useDataTableSelection({
gridApi,
});
@@ -1,8 +1,8 @@
import { computed, ref, type Ref } from 'vue';
import type { GridApi, RowSelectionOptions } from 'ag-grid-community';
import { ADD_ROW_ROW_ID } from '@/features/dataStore/constants';
import { ADD_ROW_ROW_ID } from '@/features/dataTable/constants';
export const useDataStoreSelection = ({
export const useDataTableSelection = ({
gridApi,
}: {
gridApi: Ref<GridApi>;
@@ -1,42 +1,42 @@
import type {
AGGridCellType,
DataStoreColumnType,
DataStoreValue,
} from '@/features/dataStore/datastore.types';
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
DataTableColumnType,
DataTableValue,
} from '@/features/dataTable/dataTable.types';
import { isAGGridCellType } from '@/features/dataTable/typeGuards';
import { ResponseError } from '@n8n/rest-api-client';
import { useI18n } from '@n8n/i18n';
import { DATA_TYPE_ICON_MAP } from '@/constants';
export const useDataStoreTypes = () => {
const getIconForType = (type: DataStoreColumnType) => DATA_TYPE_ICON_MAP[type];
export const useDataTableTypes = () => {
const getIconForType = (type: DataTableColumnType) => DATA_TYPE_ICON_MAP[type];
const i18n = useI18n();
/**
* Maps a DataStoreColumnType to an AGGridCellType.
* Maps a DataTableColumnType to an AGGridCellType.
* For now the only mismatch is our 'string' type,
* which needs to be mapped manually.
* @param colType The DataStoreColumnType to map.
* @param colType The DataTableColumnType to map.
* @returns The corresponding AGGridCellType.
*/
const mapToAGCellType = (colType: DataStoreColumnType): AGGridCellType => {
const mapToAGCellType = (colType: DataTableColumnType): AGGridCellType => {
if (colType === 'string') {
return 'text';
}
return colType;
};
const mapToDataStoreColumnType = (colType: AGGridCellType): DataStoreColumnType => {
const mapToDataTableColumnType = (colType: AGGridCellType): DataTableColumnType => {
if (!isAGGridCellType(colType)) {
return 'string';
}
if (colType === 'text') {
return 'string';
}
return colType as DataStoreColumnType;
return colType as DataTableColumnType;
};
const getDefaultValueForType = (colType: DataStoreColumnType): DataStoreValue => {
const getDefaultValueForType = (colType: DataTableColumnType): DataTableValue => {
switch (colType) {
case 'string':
return '';
@@ -76,7 +76,7 @@ export const useDataStoreTypes = () => {
return {
getIconForType,
mapToAGCellType,
mapToDataStoreColumnType,
mapToDataTableColumnType,
getDefaultValueForType,
getAddColumnError,
};
@@ -1,5 +1,5 @@
import { ref, nextTick, useTemplateRef } from 'vue';
import { parseLooseDateInput } from '@/features/dataStore/utils/typeUtils';
import { parseLooseDateInput } from '@/features/dataTable/utils/typeUtils';
import { isFocusableEl } from '@/utils/typesUtils';
export interface DatePickerCallbacks {
@@ -1,29 +1,29 @@
import { DATA_TABLE_COLUMN_REGEX } from '@n8n/api-types';
// Route and view identifiers
export const DATA_STORE_VIEW = 'data-stores';
export const PROJECT_DATA_STORES = 'project-data-stores';
export const DATA_STORE_DETAILS = 'data-store-details';
export const DATA_STORE_STORE = 'dataStoreStore';
export const DATA_TABLE_VIEW = 'data-tables';
export const PROJECT_DATA_TABLES = 'project-data-tables';
export const DATA_TABLE_DETAILS = 'data-table-details';
export const DATA_TABLE_STORE = 'dataTableStore';
export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
export const DEFAULT_DATA_TABLE_PAGE_SIZE = 10;
export const DATA_STORE_ID_COLUMN_WIDTH = 60;
export const DATA_TABLE_ID_COLUMN_WIDTH = 60;
export const DEFAULT_COLUMN_WIDTH = 250;
export const DATA_STORE_HEADER_HEIGHT = 36;
export const DATA_STORE_ROW_HEIGHT = 33;
export const DATA_TABLE_HEADER_HEIGHT = 36;
export const DATA_TABLE_ROW_HEIGHT = 33;
export const ADD_ROW_ROW_ID = '__n8n_add_row__';
export const DATA_STORE_CARD_ACTIONS = {
export const DATA_TABLE_CARD_ACTIONS = {
RENAME: 'rename',
DELETE: 'delete',
CLEAR: 'clear',
};
export const ADD_DATA_STORE_MODAL_KEY = 'addDataStoreModal';
export const ADD_DATA_TABLE_MODAL_KEY = 'addDataTableModal';
export const DEFAULT_ID_COLUMN_NAME = 'id';
@@ -36,7 +36,7 @@ export const MIN_LOADING_TIME = 500; // ms
export const NULL_VALUE = 'Null';
export const EMPTY_VALUE = 'Empty';
export const DATA_STORE_MODULE_NAME = 'data-table';
export const DATA_TABLE_MODULE_NAME = 'data-table';
export const NUMBER_WITH_SPACES_REGEX = /\B(?=(\d{3})+(?!\d))/g;
export const NUMBER_THOUSAND_SEPARATOR = ' ';
@@ -1,4 +1,4 @@
import { deleteDataStoreRowsApi } from '@/features/dataStore/dataStore.api';
import { deleteDataTableRowsApi } from '@/features/dataTable/dataTable.api';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import { expect } from 'vitest';
@@ -6,22 +6,22 @@ vi.mock('@n8n/rest-api-client', () => ({
makeRestApiRequest: vi.fn(),
}));
describe('dataStore.api', () => {
describe('dataTable.api', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('deleteDataStoreRowsApi', () => {
describe('deleteDataTableRowsApi', () => {
it('should make DELETE request with correct parameters', async () => {
const dataStoreId = 'test-datastore-id';
const dataTableId = 'test-dataTable-id';
const projectId = 'test-project-id';
const rowIds = [1, 2, 3];
vi.mocked(makeRestApiRequest).mockResolvedValue(true);
const result = await deleteDataStoreRowsApi(
const result = await deleteDataTableRowsApi(
{ baseUrl: '/rest', pushRef: 'test-push-ref' },
dataStoreId,
dataTableId,
rowIds,
projectId,
);
@@ -29,7 +29,7 @@ describe('dataStore.api', () => {
expect(makeRestApiRequest).toHaveBeenCalledWith(
expect.anything(),
'DELETE',
`/projects/${projectId}/data-tables/${dataStoreId}/rows`,
`/projects/${projectId}/data-tables/${dataTableId}/rows`,
{
filter: {
type: 'or',
@@ -2,14 +2,14 @@ import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type {
DataStoreColumnCreatePayload,
DataStore,
DataStoreColumn,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
DataTableColumnCreatePayload,
DataTable,
DataTableColumn,
DataTableRow,
} from '@/features/dataTable/dataTable.types';
import type { DataTablesSizeResult } from 'n8n-workflow';
export const fetchDataStoresApi = async (
export const fetchDataTablesApi = async (
context: IRestApiContext,
projectId: string,
options?: {
@@ -23,7 +23,7 @@ export const fetchDataStoresApi = async (
},
) => {
const apiEndpoint = projectId ? `/projects/${projectId}/data-tables` : '/data-tables-global';
return await makeRestApiRequest<{ count: number; data: DataStore[] }>(
return await makeRestApiRequest<{ count: number; data: DataTable[] }>(
context,
'GET',
apiEndpoint,
@@ -34,13 +34,13 @@ export const fetchDataStoresApi = async (
);
};
export const createDataStoreApi = async (
export const createDataTableApi = async (
context: IRestApiContext,
name: string,
projectId: string,
columns?: DataStoreColumnCreatePayload[],
columns?: DataTableColumnCreatePayload[],
) => {
return await makeRestApiRequest<DataStore>(
return await makeRestApiRequest<DataTable>(
context,
'POST',
`/projects/${projectId}/data-tables`,
@@ -51,70 +51,70 @@ export const createDataStoreApi = async (
);
};
export const deleteDataStoreApi = async (
export const deleteDataTableApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
projectId: string,
) => {
return await makeRestApiRequest<boolean>(
context,
'DELETE',
`/projects/${projectId}/data-tables/${dataStoreId}`,
`/projects/${projectId}/data-tables/${dataTableId}`,
{
dataStoreId,
dataTableId,
projectId,
},
);
};
export const updateDataStoreApi = async (
export const updateDataTableApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
name: string,
projectId: string,
) => {
return await makeRestApiRequest<DataStore>(
return await makeRestApiRequest<DataTable>(
context,
'PATCH',
`/projects/${projectId}/data-tables/${dataStoreId}`,
`/projects/${projectId}/data-tables/${dataTableId}`,
{
name,
},
);
};
export const addDataStoreColumnApi = async (
export const addDataTableColumnApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
projectId: string,
column: DataStoreColumnCreatePayload,
column: DataTableColumnCreatePayload,
) => {
return await makeRestApiRequest<DataStoreColumn>(
return await makeRestApiRequest<DataTableColumn>(
context,
'POST',
`/projects/${projectId}/data-tables/${dataStoreId}/columns`,
`/projects/${projectId}/data-tables/${dataTableId}/columns`,
{
...column,
},
);
};
export const deleteDataStoreColumnApi = async (
export const deleteDataTableColumnApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
projectId: string,
columnId: string,
) => {
return await makeRestApiRequest<boolean>(
context,
'DELETE',
`/projects/${projectId}/data-tables/${dataStoreId}/columns/${columnId}`,
`/projects/${projectId}/data-tables/${dataTableId}/columns/${columnId}`,
);
};
export const moveDataStoreColumnApi = async (
export const moveDataTableColumnApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
projectId: string,
columnId: string,
targetIndex: number,
@@ -122,16 +122,16 @@ export const moveDataStoreColumnApi = async (
return await makeRestApiRequest<boolean>(
context,
'PATCH',
`/projects/${projectId}/data-tables/${dataStoreId}/columns/${columnId}/move`,
`/projects/${projectId}/data-tables/${dataTableId}/columns/${columnId}/move`,
{
targetIndex,
},
);
};
export const getDataStoreRowsApi = async (
export const getDataTableRowsApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
projectId: string,
options?: {
skip?: number;
@@ -142,22 +142,22 @@ export const getDataStoreRowsApi = async (
) => {
return await makeRestApiRequest<{
count: number;
data: DataStoreRow[];
}>(context, 'GET', `/projects/${projectId}/data-tables/${dataStoreId}/rows`, {
data: DataTableRow[];
}>(context, 'GET', `/projects/${projectId}/data-tables/${dataTableId}/rows`, {
...(options ?? {}),
});
};
export const insertDataStoreRowApi = async (
export const insertDataTableRowApi = async (
context: IRestApiContext,
dataStoreId: string,
row: DataStoreRow,
dataTableId: string,
row: DataTableRow,
projectId: string,
) => {
return await makeRestApiRequest<DataStoreRow[]>(
return await makeRestApiRequest<DataTableRow[]>(
context,
'POST',
`/projects/${projectId}/data-tables/${dataStoreId}/insert`,
`/projects/${projectId}/data-tables/${dataTableId}/insert`,
{
returnType: 'all',
data: [row],
@@ -165,17 +165,17 @@ export const insertDataStoreRowApi = async (
);
};
export const updateDataStoreRowsApi = async (
export const updateDataTableRowsApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
rowId: number,
rowData: DataStoreRow,
rowData: DataTableRow,
projectId: string,
) => {
return await makeRestApiRequest<boolean>(
context,
'PATCH',
`/projects/${projectId}/data-tables/${dataStoreId}/rows`,
`/projects/${projectId}/data-tables/${dataTableId}/rows`,
{
filter: {
type: 'and',
@@ -186,17 +186,21 @@ export const updateDataStoreRowsApi = async (
);
};
export const deleteDataStoreRowsApi = async (
export const deleteDataTableRowsApi = async (
context: IRestApiContext,
dataStoreId: string,
dataTableId: string,
rowIds: number[],
projectId: string,
) => {
const filters = rowIds.map((id) => ({ columnName: 'id', condition: 'eq', value: id }));
const filters = rowIds.map((id) => ({
columnName: 'id',
condition: 'eq',
value: id,
}));
return await makeRestApiRequest<boolean>(
context,
'DELETE',
`/projects/${projectId}/data-tables/${dataStoreId}/rows`,
`/projects/${projectId}/data-tables/${dataTableId}/rows`,
{
filter: {
type: 'or',
@@ -206,7 +210,7 @@ export const deleteDataStoreRowsApi = async (
);
};
export const fetchDataStoreGlobalLimitInBytes = async (context: IRestApiContext) => {
export const fetchDataTableGlobalLimitInBytes = async (context: IRestApiContext) => {
return await makeRestApiRequest<DataTablesSizeResult>(
context,
'GET',
@@ -1,17 +1,17 @@
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
import { faker } from '@faker-js/faker';
import { useRootStore } from '@n8n/stores/useRootStore';
import { createPinia, setActivePinia } from 'pinia';
import * as dataStoreApi from '@/features/dataStore/dataStore.api';
import * as dataTableApi from '@/features/dataTable/dataTable.api';
describe('dataStore.store', () => {
let dataStoreStore: ReturnType<typeof useDataStoreStore>;
describe('dataTable.store', () => {
let dataTableStore: ReturnType<typeof useDataTableStore>;
let rootStore: ReturnType<typeof useRootStore>;
beforeEach(() => {
setActivePinia(createPinia());
rootStore = useRootStore();
dataStoreStore = useDataStoreStore();
dataTableStore = useDataTableStore();
});
afterEach(() => {
@@ -19,14 +19,14 @@ describe('dataStore.store', () => {
});
it('can move a column', async () => {
const datastoreId = faker.string.alphanumeric(10);
const dataTableId = faker.string.alphanumeric(10);
const columnId = 'phone';
const targetIndex = 3;
const projectId = 'p1';
dataStoreStore.$patch({
dataStores: [
dataTableStore.$patch({
dataTables: [
{
id: datastoreId,
id: dataTableId,
name: 'Test',
sizeBytes: 0,
createdAt: '2024-01-01T00:00:00.000Z',
@@ -43,65 +43,65 @@ describe('dataStore.store', () => {
],
totalCount: 1,
});
vi.spyOn(dataStoreApi, 'moveDataStoreColumnApi').mockResolvedValue(true);
vi.spyOn(dataTableApi, 'moveDataTableColumnApi').mockResolvedValue(true);
const moved = await dataStoreStore.moveDataStoreColumn(
datastoreId,
const moved = await dataTableStore.moveDataTableColumn(
dataTableId,
projectId,
columnId,
targetIndex,
);
expect(moved).toBe(true);
expect(dataStoreApi.moveDataStoreColumnApi).toHaveBeenCalledWith(
expect(dataTableApi.moveDataTableColumnApi).toHaveBeenCalledWith(
rootStore.restApiContext,
datastoreId,
dataTableId,
projectId,
columnId,
targetIndex,
);
expect(dataStoreStore.dataStores[0].columns.find((c) => c.id === columnId)?.index).toBe(
expect(dataTableStore.dataTables[0].columns.find((c) => c.id === columnId)?.index).toBe(
targetIndex,
);
});
it('can delete a column', async () => {
const datastoreId = faker.string.alphanumeric(10);
const dataTableId = faker.string.alphanumeric(10);
const columnId = 'phone';
const projectId = 'p1';
dataStoreStore.$patch({
dataStores: [
{ id: datastoreId, columns: [{ id: columnId, index: 0, name: 'phone', type: 'string' }] },
dataTableStore.$patch({
dataTables: [
{ id: dataTableId, columns: [{ id: columnId, index: 0, name: 'phone', type: 'string' }] },
],
totalCount: 1,
});
vi.spyOn(dataStoreApi, 'deleteDataStoreColumnApi').mockResolvedValue(true);
vi.spyOn(dataTableApi, 'deleteDataTableColumnApi').mockResolvedValue(true);
const deleted = await dataStoreStore.deleteDataStoreColumn(datastoreId, projectId, columnId);
const deleted = await dataTableStore.deleteDataTableColumn(dataTableId, projectId, columnId);
expect(deleted).toBe(true);
expect(dataStoreApi.deleteDataStoreColumnApi).toHaveBeenCalledWith(
expect(dataTableApi.deleteDataTableColumnApi).toHaveBeenCalledWith(
rootStore.restApiContext,
datastoreId,
dataTableId,
projectId,
columnId,
);
expect(dataStoreStore.dataStores[0].columns.find((c) => c.id === columnId)).toBeUndefined();
expect(dataTableStore.dataTables[0].columns.find((c) => c.id === columnId)).toBeUndefined();
});
it('can delete rows', async () => {
const datastoreId = faker.string.alphanumeric(10);
const dataTableId = faker.string.alphanumeric(10);
const projectId = 'p1';
const rowIds = [1, 2, 3];
vi.spyOn(dataStoreApi, 'deleteDataStoreRowsApi').mockResolvedValue(true);
vi.spyOn(dataTableApi, 'deleteDataTableRowsApi').mockResolvedValue(true);
const result = await dataStoreStore.deleteRows(datastoreId, projectId, rowIds);
const result = await dataTableStore.deleteRows(dataTableId, projectId, rowIds);
expect(result).toBe(true);
expect(dataStoreApi.deleteDataStoreRowsApi).toHaveBeenCalledWith(
expect(dataTableApi.deleteDataTableRowsApi).toHaveBeenCalledWith(
rootStore.restApiContext,
datastoreId,
dataTableId,
rowIds,
projectId,
);
@@ -0,0 +1,271 @@
import { defineStore } from 'pinia';
import { DATA_TABLE_STORE } from '@/features/dataTable/constants';
import { computed, ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import {
fetchDataTablesApi,
createDataTableApi,
deleteDataTableApi,
updateDataTableApi,
addDataTableColumnApi,
deleteDataTableColumnApi,
moveDataTableColumnApi,
getDataTableRowsApi,
insertDataTableRowApi,
updateDataTableRowsApi,
deleteDataTableRowsApi,
fetchDataTableGlobalLimitInBytes,
} from '@/features/dataTable/dataTable.api';
import type {
DataTable,
DataTableColumnCreatePayload,
DataTableRow,
} from '@/features/dataTable/dataTable.types';
import { useProjectsStore } from '@/stores/projects.store';
import { reorderItem } from '@/features/dataTable/utils';
import { type DataTableSizeStatus } from 'n8n-workflow';
import { useSettingsStore } from '@/stores/settings.store';
export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => {
const rootStore = useRootStore();
const projectStore = useProjectsStore();
const settingsStore = useSettingsStore();
const dataTables = ref<DataTable[]>([]);
const totalCount = ref(0);
const dataTableSize = ref(0);
const dataTableSizeLimitState = ref<DataTableSizeStatus>('ok');
const dataTableTableSizes = ref<Record<string, number>>({});
const formatSize = (sizeBytes: number) => {
return Number((sizeBytes / 1024 / 1024).toFixed(2));
};
const maxSizeMB = computed(() =>
Math.floor(settingsStore.settings?.dataTables?.maxSize / 1024 / 1024),
);
const dataTableSizes = computed(() => {
const formattedSizes: Record<string, number> = {};
for (const [dataTableId, sizeBytes] of Object.entries(dataTableTableSizes.value)) {
formattedSizes[dataTableId] = formatSize(sizeBytes);
}
return formattedSizes;
});
const fetchDataTables = async (projectId: string, page: number, pageSize: number) => {
const response = await fetchDataTablesApi(rootStore.restApiContext, projectId, {
skip: (page - 1) * pageSize,
take: pageSize,
});
dataTables.value = response.data;
totalCount.value = response.count;
};
const createDataTable = async (name: string, projectId: string) => {
const newTable = await createDataTableApi(rootStore.restApiContext, name, projectId);
if (!newTable.project && projectId) {
const project = await projectStore.fetchProject(projectId);
if (project) {
newTable.project = project;
}
}
dataTables.value.push(newTable);
totalCount.value += 1;
return newTable;
};
const deleteDataTable = async (dataTableId: string, projectId: string) => {
const deleted = await deleteDataTableApi(rootStore.restApiContext, dataTableId, projectId);
if (deleted) {
dataTables.value = dataTables.value.filter((dataTable) => dataTable.id !== dataTableId);
totalCount.value -= 1;
}
return deleted;
};
const deleteDataTableColumn = async (
dataTableId: string,
projectId: string,
columnId: string,
) => {
const deleted = await deleteDataTableColumnApi(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
);
if (deleted) {
const index = dataTables.value.findIndex((dataTable) => dataTable.id === dataTableId);
if (index !== -1) {
dataTables.value[index].columns = dataTables.value[index].columns.filter(
(column) => column.id !== columnId,
);
}
}
return deleted;
};
const updateDataTable = async (dataTableId: string, name: string, projectId: string) => {
const updated = await updateDataTableApi(
rootStore.restApiContext,
dataTableId,
name,
projectId,
);
if (updated) {
const index = dataTables.value.findIndex((table) => table.id === dataTableId);
if (index !== -1) {
dataTables.value[index] = { ...dataTables.value[index], name };
}
}
return updated;
};
const fetchDataTableDetails = async (dataTableId: string, projectId: string) => {
const response = await fetchDataTablesApi(rootStore.restApiContext, projectId, undefined, {
projectId,
id: dataTableId,
});
if (response.data.length > 0) {
dataTables.value = response.data;
return response.data[0];
}
return null;
};
const fetchOrFindDataTable = async (dataTableId: string, projectId: string) => {
const existingTable = dataTables.value.find((table) => table.id === dataTableId);
if (existingTable) {
return existingTable;
}
return await fetchDataTableDetails(dataTableId, projectId);
};
const addDataTableColumn = async (
dataTableId: string,
projectId: string,
column: DataTableColumnCreatePayload,
) => {
const newColumn = await addDataTableColumnApi(
rootStore.restApiContext,
dataTableId,
projectId,
column,
);
if (newColumn) {
const index = dataTables.value.findIndex((table) => table.id === dataTableId);
if (index !== -1) {
dataTables.value[index].columns.push(newColumn);
}
}
return newColumn;
};
const moveDataTableColumn = async (
dataTableId: string,
projectId: string,
columnId: string,
targetIndex: number,
) => {
const moved = await moveDataTableColumnApi(
rootStore.restApiContext,
dataTableId,
projectId,
columnId,
targetIndex,
);
if (moved) {
const dsIndex = dataTables.value.findIndex((table) => table.id === dataTableId);
const fromIndex = dataTables.value[dsIndex].columns.findIndex((col) => col.id === columnId);
dataTables.value[dsIndex].columns = reorderItem(
dataTables.value[dsIndex].columns,
fromIndex,
targetIndex,
);
}
return moved;
};
const fetchDataTableContent = async (
dataTableId: string,
projectId: string,
page: number,
pageSize: number,
sortBy: string,
filter?: string,
) => {
return await getDataTableRowsApi(rootStore.restApiContext, dataTableId, projectId, {
skip: (page - 1) * pageSize,
take: pageSize,
sortBy,
filter,
});
};
const insertEmptyRow = async (dataTableId: string, projectId: string) => {
const inserted = await insertDataTableRowApi(
rootStore.restApiContext,
dataTableId,
{},
projectId,
);
return inserted[0];
};
const updateRow = async (
dataTableId: string,
projectId: string,
rowId: number,
rowData: DataTableRow,
) => {
return await updateDataTableRowsApi(
rootStore.restApiContext,
dataTableId,
rowId,
rowData,
projectId,
);
};
const deleteRows = async (dataTableId: string, projectId: string, rowIds: number[]) => {
return await deleteDataTableRowsApi(rootStore.restApiContext, dataTableId, rowIds, projectId);
};
const fetchDataTableSize = async () => {
const result = await fetchDataTableGlobalLimitInBytes(rootStore.restApiContext);
dataTableSize.value = formatSize(result.totalBytes);
dataTableSizeLimitState.value = result.quotaStatus;
const tableSizes: Record<string, number> = {};
for (const [dataTableId, info] of Object.entries(result.dataTables)) {
tableSizes[dataTableId] = info.sizeBytes;
}
dataTableTableSizes.value = tableSizes;
return result;
};
return {
dataTables,
totalCount,
fetchDataTables,
fetchDataTableSize,
dataTableSize: computed(() => dataTableSize.value),
dataTableSizeLimitState: computed(() => dataTableSizeLimitState.value),
dataTableSizes,
maxSizeMB,
createDataTable,
deleteDataTable,
updateDataTable,
fetchDataTableDetails,
fetchOrFindDataTable,
addDataTableColumn,
deleteDataTableColumn,
moveDataTableColumn,
fetchDataTableContent,
insertEmptyRow,
updateRow,
deleteRows,
};
});
@@ -1,10 +1,10 @@
import type { Project } from '@/types/projects.types';
export type DataStore = {
export type DataTable = {
id: string;
name: string;
sizeBytes: number;
columns: DataStoreColumn[];
columns: DataTableColumn[];
createdAt: string;
updatedAt: string;
projectId: string;
@@ -12,8 +12,8 @@ export type DataStore = {
};
// Single sources of truth for supported types
export const DATA_STORE_COLUMN_TYPES = ['string', 'number', 'boolean', 'date'] as const;
export type DataStoreColumnType = (typeof DATA_STORE_COLUMN_TYPES)[number];
export const DATA_TABLE_COLUMN_TYPES = ['string', 'number', 'boolean', 'date'] as const;
export type DataTableColumnType = (typeof DATA_TABLE_COLUMN_TYPES)[number];
export const AG_GRID_CELL_TYPES = [
'text',
@@ -25,18 +25,18 @@ export const AG_GRID_CELL_TYPES = [
] as const;
export type AGGridCellType = (typeof AG_GRID_CELL_TYPES)[number];
export type DataStoreColumn = {
export type DataTableColumn = {
id: string;
name: string;
type: DataStoreColumnType;
type: DataTableColumnType;
index: number;
};
export type DataStoreColumnCreatePayload = Pick<DataStoreColumn, 'name' | 'type'>;
export type DataTableColumnCreatePayload = Pick<DataTableColumn, 'name' | 'type'>;
export type DataStoreValue = string | number | boolean | Date | null;
export type DataTableValue = string | number | boolean | Date | null;
export type DataStoreRow = Record<string, DataStoreValue>;
export type DataTableRow = Record<string, DataTableValue>;
export type AddColumnResponse = {
success: boolean;
@@ -1,37 +1,37 @@
import { useI18n } from '@n8n/i18n';
import { type FrontendModuleDescription } from '@/moduleInitializer/module.types';
import {
ADD_DATA_STORE_MODAL_KEY,
DATA_STORE_DETAILS,
DATA_STORE_VIEW,
PROJECT_DATA_STORES,
} from '@/features/dataStore/constants';
ADD_DATA_TABLE_MODAL_KEY,
DATA_TABLE_DETAILS,
DATA_TABLE_VIEW,
PROJECT_DATA_TABLES,
} from '@/features/dataTable/constants';
const i18n = useI18n();
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const DataStoreView = async () => await import('@/features/dataStore/DataStoreView.vue');
const DataStoreDetailsView = async () =>
await import('@/features/dataStore/DataStoreDetailsView.vue');
const DataTableView = async () => await import('@/features/dataTable/DataTableView.vue');
const DataTableDetailsView = async () =>
await import('@/features/dataTable/DataTableDetailsView.vue');
export const DataTableModule: FrontendModuleDescription = {
id: 'data-table',
name: 'Data Store',
description: 'Manage and store data efficiently with the Data Store module.',
name: 'Data Table',
description: 'Manage and store data efficiently with the Data Table module.',
icon: 'database',
modals: [
{
key: ADD_DATA_STORE_MODAL_KEY,
component: async () => await import('./components/AddDataStoreModal.vue'),
key: ADD_DATA_TABLE_MODAL_KEY,
component: async () => await import('./components/AddDataTableModal.vue'),
initialState: { open: false },
},
],
routes: [
{
name: DATA_STORE_VIEW,
name: DATA_TABLE_VIEW,
path: '/home/datatables',
components: {
default: DataStoreView,
default: DataTableView,
sidebar: MainSidebar,
},
meta: {
@@ -39,11 +39,11 @@ export const DataTableModule: FrontendModuleDescription = {
},
},
{
name: PROJECT_DATA_STORES,
name: PROJECT_DATA_TABLES,
path: 'datatables/:new(new)?',
props: true,
components: {
default: DataStoreView,
default: DataTableView,
sidebar: MainSidebar,
},
meta: {
@@ -52,11 +52,11 @@ export const DataTableModule: FrontendModuleDescription = {
},
},
{
name: DATA_STORE_DETAILS,
name: DATA_TABLE_DETAILS,
path: 'datatables/:id',
props: true,
components: {
default: DataStoreDetailsView,
default: DataTableDetailsView,
sidebar: MainSidebar,
},
meta: {
@@ -68,21 +68,21 @@ export const DataTableModule: FrontendModuleDescription = {
projectTabs: {
overview: [
{
label: i18n.baseText('dataStore.dataStores'),
value: DATA_STORE_VIEW,
label: i18n.baseText('dataTable.dataTables'),
value: DATA_TABLE_VIEW,
tag: i18n.baseText('generic.betaProper'),
to: {
name: DATA_STORE_VIEW,
name: DATA_TABLE_VIEW,
},
},
],
project: [
{
label: i18n.baseText('dataStore.dataStores'),
value: PROJECT_DATA_STORES,
label: i18n.baseText('dataTable.dataTables'),
value: PROJECT_DATA_TABLES,
tag: i18n.baseText('generic.betaProper'),
dynamicRoute: {
name: PROJECT_DATA_STORES,
name: PROJECT_DATA_TABLES,
includeProjectId: true,
},
},
@@ -90,8 +90,8 @@ export const DataTableModule: FrontendModuleDescription = {
},
resources: [
{
key: 'dataStore',
displayName: 'Data Store',
key: 'dataTable',
displayName: 'Data Table',
},
],
};
@@ -0,0 +1,24 @@
import type {
AGGridCellType,
DataTableValue,
DataTableColumnType,
} from '@/features/dataTable/dataTable.types';
import { AG_GRID_CELL_TYPES, DATA_TABLE_COLUMN_TYPES } from '@/features/dataTable/dataTable.types';
export const isDataTableValue = (value: unknown): value is DataTableValue => {
return (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value instanceof Date
);
};
export const isAGGridCellType = (value: unknown): value is AGGridCellType => {
return typeof value === 'string' && (AG_GRID_CELL_TYPES as readonly string[]).includes(value);
};
export const isDataTableColumnType = (type: unknown): type is DataTableColumnType => {
return typeof type === 'string' && (DATA_TABLE_COLUMN_TYPES as readonly string[]).includes(type);
};
@@ -0,0 +1,21 @@
import type { BaseResource } from '@/Interface';
import type { DataTable } from '@/features/dataTable/dataTable.types';
/**
* Data Table resource type definition
* This extends the ModuleResources interface to add DataTable as a resource type
*/
export type DataTableResource = BaseResource &
DataTable & {
resourceType: 'dataTable';
};
// Extend the ModuleResources interface to include DataTable
declare module '@/Interface' {
interface ModuleResources {
dataTable: DataTableResource;
}
}
// Export to make this a module
export {};
@@ -9,7 +9,7 @@ import type {
import type { Ref } from 'vue';
import { DateTime } from 'luxon';
import type { I18nClass } from '@n8n/i18n';
import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types';
import type { DataTableColumn, DataTableRow } from '@/features/dataTable/dataTable.types';
import {
ADD_ROW_ROW_ID,
EMPTY_VALUE,
@@ -17,9 +17,9 @@ import {
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';
} from '@/features/dataTable/constants';
import NullEmptyCellRenderer from '@/features/dataTable/components/dataGrid/NullEmptyCellRenderer.vue';
import { isDataTableValue } from '@/features/dataTable/typeGuards';
export const getCellClass = (params: CellClassParams): string => {
if (params.data?.id === ADD_ROW_ROW_ID) {
@@ -32,7 +32,7 @@ export const getCellClass = (params: CellClassParams): string => {
};
export const createValueGetter =
(col: DataStoreColumn) => (params: ValueGetterParams<DataStoreRow>) => {
(col: DataTableColumn) => (params: ValueGetterParams<DataTableRow>) => {
if (params.data?.[col.name] === null || params.data?.[col.name] === undefined) {
return null;
}
@@ -46,33 +46,39 @@ export const createValueGetter =
};
export const createCellRendererSelector =
(col: DataStoreColumn) => (params: ICellRendererParams) => {
(col: DataTableColumn) => (params: ICellRendererParams) => {
if (params.data?.id === ADD_ROW_ROW_ID || col.id === 'add-column') {
return {};
}
let rowValue = (params.data as DataStoreRow | undefined)?.[col.name];
let rowValue = (params.data as DataTableRow | undefined)?.[col.name];
if (rowValue === undefined) {
rowValue = null;
}
if (rowValue === null) {
return { component: NullEmptyCellRenderer, params: { value: NULL_VALUE } };
return {
component: NullEmptyCellRenderer,
params: { value: NULL_VALUE },
};
}
if (rowValue === '') {
return { component: NullEmptyCellRenderer, params: { value: EMPTY_VALUE } };
return {
component: NullEmptyCellRenderer,
params: { value: EMPTY_VALUE },
};
}
return undefined;
};
export const createStringValueSetter =
(col: DataStoreColumn, isTextEditorOpen: Ref<boolean>) =>
(params: ValueSetterParams<DataStoreRow>) => {
(col: DataTableColumn, isTextEditorOpen: Ref<boolean>) =>
(params: ValueSetterParams<DataTableRow>) => {
let originalValue = params.data[col.name];
if (originalValue === undefined) {
originalValue = null;
}
let newValue = params.newValue as unknown as DataStoreRow[keyof DataStoreRow];
let newValue = params.newValue as unknown as DataTableRow[keyof DataTableRow];
if (!isDataStoreValue(newValue)) {
if (!isDataTableValue(newValue)) {
return false;
}
@@ -89,14 +95,14 @@ export const createStringValueSetter =
};
export const stringCellEditorParams = (
params: CellEditRequestEvent<DataStoreRow>,
params: CellEditRequestEvent<DataTableRow>,
): { value: string; maxLength: number } => ({
value: (params.value as string | null | undefined) ?? '',
maxLength: 999999999,
});
export const dateValueFormatter = (
params: ValueFormatterParams<DataStoreRow, Date | null | undefined>,
params: ValueFormatterParams<DataTableRow, Date | null | undefined>,
): string => {
const value = params.value;
if (value === null || value === undefined) return '';
@@ -111,7 +117,7 @@ const numberWithSpaces = (num: number) => {
};
export const numberValueFormatter = (
params: ValueFormatterParams<DataStoreRow, number | null | undefined>,
params: ValueFormatterParams<DataTableRow, number | null | undefined>,
): string => {
const value = params.value;
if (value === null || value === undefined) return '';
@@ -120,70 +126,70 @@ export const numberValueFormatter = (
const createNullFilterOption = (i18n: I18nClass) => ({
displayKey: 'null',
displayName: i18n.baseText('dataStore.filters.isNull'),
displayName: i18n.baseText('dataTable.filters.isNull'),
predicate: () => true,
numberOfInputs: 0,
});
const createNotNullFilterOption = (i18n: I18nClass) => ({
displayKey: 'notNull',
displayName: i18n.baseText('dataStore.filters.isNotNull'),
displayName: i18n.baseText('dataTable.filters.isNotNull'),
predicate: () => true,
numberOfInputs: 0,
});
const createIsEmptyFilterOption = (i18n: I18nClass) => ({
displayKey: 'isEmpty',
displayName: i18n.baseText('dataStore.filters.isEmpty'),
displayName: i18n.baseText('dataTable.filters.isEmpty'),
predicate: () => true,
numberOfInputs: 0,
});
const createIsNotEmptyFilterOption = (i18n: I18nClass) => ({
displayKey: 'notEmpty',
displayName: i18n.baseText('dataStore.filters.isNotEmpty'),
displayName: i18n.baseText('dataTable.filters.isNotEmpty'),
predicate: () => true,
numberOfInputs: 0,
});
const createBetweenFilterOption = (i18n: I18nClass) => ({
displayKey: 'between',
displayName: i18n.baseText('dataStore.filters.between'),
displayName: i18n.baseText('dataTable.filters.between'),
predicate: () => true,
numberOfInputs: 2,
});
const createGreaterThanFilterOption = (i18n: I18nClass) => ({
displayKey: 'greaterThan',
displayName: i18n.baseText('dataStore.filters.greaterThan'),
displayName: i18n.baseText('dataTable.filters.greaterThan'),
predicate: () => true,
numberOfInputs: 1,
});
const createGreaterThanOrEqualFilterOption = (i18n: I18nClass) => ({
displayKey: 'greaterThanOrEqual',
displayName: i18n.baseText('dataStore.filters.greaterThanOrEqual'),
displayName: i18n.baseText('dataTable.filters.greaterThanOrEqual'),
predicate: () => true,
numberOfInputs: 1,
});
const createLessThanFilterOption = (i18n: I18nClass) => ({
displayKey: 'lessThan',
displayName: i18n.baseText('dataStore.filters.lessThan'),
displayName: i18n.baseText('dataTable.filters.lessThan'),
predicate: () => true,
numberOfInputs: 1,
});
const createLessThanOrEqualFilterOption = (i18n: I18nClass) => ({
displayKey: 'lessThanOrEqual',
displayName: i18n.baseText('dataStore.filters.lessThanOrEqual'),
displayName: i18n.baseText('dataTable.filters.lessThanOrEqual'),
predicate: () => true,
numberOfInputs: 1,
});
const createInRangeFilterOption = (i18n: I18nClass) => ({
displayKey: 'inRange',
displayName: i18n.baseText('dataStore.filters.between'),
displayName: i18n.baseText('dataTable.filters.between'),
predicate: () => true,
numberOfInputs: 2,
});
@@ -226,13 +232,13 @@ export const getBooleanColumnFilterOptions = (i18n: I18nClass) => [
'empty',
{
displayKey: 'true',
displayName: i18n.baseText('dataStore.filters.true'),
displayName: i18n.baseText('dataTable.filters.true'),
numberOfInputs: 0,
predicate: () => true,
},
{
displayKey: 'false',
displayName: i18n.baseText('dataStore.filters.false'),
displayName: i18n.baseText('dataTable.filters.false'),
numberOfInputs: 0,
predicate: () => true,
},
@@ -1,4 +1,4 @@
import type { BackendFilterCondition, FilterOperation } from '../types/dataStoreFilters.types';
import type { BackendFilterCondition, FilterOperation } from '../types/dataTableFilters.types';
export const SPECIAL_COLUMNS = ['add-column', 'ag-Grid-SelectionColumn'] as const;
export const isSpecialColumn = (value: unknown): value is (typeof SPECIAL_COLUMNS)[number] =>
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { processTextFilter, processNumberFilter, processDateFilter } from './filterProcessors';
import type { FilterModel, FilterOperation } from '../types/dataStoreFilters.types';
import type { FilterModel, FilterOperation } from '../types/dataTableFilters.types';
vi.mock('./filterMappings', () => ({
mapTextTypeToBackend: vi.fn((type: string) => {
@@ -1,4 +1,4 @@
import type { BackendFilterRecord, FilterModel } from '../types/dataStoreFilters.types';
import type { BackendFilterRecord, FilterModel } from '../types/dataTableFilters.types';
import { mapTextTypeToBackend, mapNumberDateTypeToBackend } from './filterMappings';
export function processTextFilter(
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { parseLooseDateInput, areValuesEqual } from '@/features/dataStore/utils/typeUtils';
import { parseLooseDateInput, areValuesEqual } from '@/features/dataTable/utils/typeUtils';
describe('Loose Date Parsing', () => {
it('parses full loose datetime with single-digit parts', () => {
@@ -1,5 +1,5 @@
import { LOOSE_DATE_REGEX } from '@/features/dataStore/constants';
import type { DataStoreColumnType } from '@/features/dataStore/datastore.types';
import { LOOSE_DATE_REGEX } from '@/features/dataTable/constants';
import type { DataTableColumnType } from '@/features/dataTable/dataTable.types';
/**
* Parses a loose date string into a Date object.
@@ -43,13 +43,13 @@ export const parseLooseDateInput = (text: string): Date | null => {
* Check if two column values are equal, with special handling for date types.
* @param oldValue unknown
* @param newValue unknown
* @param type DataStoreColumnType | undefined
* @param type DataTableColumnType | undefined
* @returns boolean
*/
export const areValuesEqual = (
oldValue: unknown,
newValue: unknown,
type: DataStoreColumnType | undefined,
type: DataTableColumnType | undefined,
) => {
if (type && type === 'date') {
if (oldValue instanceof Date && newValue instanceof Date) {
+6 -6
View File
@@ -28,7 +28,7 @@ import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { h } from 'vue';
import { useRolesStore } from './stores/roles.store';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
export const state = {
initialized: false,
@@ -136,7 +136,7 @@ export async function initializeAuthenticatedFeatures(
const insightsStore = useInsightsStore();
const uiStore = useUIStore();
const versionsStore = useVersionsStore();
const dataStoreStore = useDataStoreStore();
const dataTableStore = useDataTableStore();
if (sourceControlStore.isEnterpriseSourceControlEnabled) {
try {
@@ -176,13 +176,13 @@ export async function initializeAuthenticatedFeatures(
}
if (settingsStore.isDataTableFeatureEnabled) {
void dataStoreStore
.fetchDataStoreSize()
void dataTableStore
.fetchDataTableSize()
.then(({ quotaStatus }) => {
if (quotaStatus === 'error') {
uiStore.pushBannerToStack('DATA_STORE_STORAGE_LIMIT_ERROR');
uiStore.pushBannerToStack('DATA_TABLE_STORAGE_LIMIT_ERROR');
} else if (quotaStatus === 'warn') {
uiStore.pushBannerToStack('DATA_STORE_STORAGE_LIMIT_WARNING');
uiStore.pushBannerToStack('DATA_TABLE_STORAGE_LIMIT_WARNING');
}
})
.catch((error) => {
@@ -1,6 +1,6 @@
import { type Router } from 'vue-router';
import { VIEWS } from '@/constants';
import { DataTableModule } from '@/features/dataStore/module.descriptor';
import { DataTableModule } from '@/features/dataTable/module.descriptor';
import { registerResource } from '@/moduleInitializer/resourceRegistry';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia';
import { ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useRootStore } from '@n8n/stores/useRootStore';
import * as dataStoreApi from '@/features/dataStore/dataStore.api';
import * as dataTableApi from '@/features/dataTable/dataTable.api';
import * as projectsApi from '@/api/projects.api';
import * as workflowsApi from '@/api/workflows';
import * as workflowsEEApi from '@/api/workflows.ee';
@@ -255,7 +255,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const [credentials, workflows, dataTables] = await Promise.all([
credentialsApi.getAllCredentials(rootStore.restApiContext, { projectId }),
workflowsApi.getWorkflows(rootStore.restApiContext, { projectId }),
dataStoreApi.fetchDataStoresApi(rootStore.restApiContext, projectId),
dataTableApi.fetchDataTablesApi(rootStore.restApiContext, projectId),
]);
return {
@@ -38,6 +38,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
securityAudit: {},
folder: {},
insights: {},
// TODO
dataStore: {},
execution: {},
workflowTags: {},