From d6ee6067cf8e5cd5ed2f9a2dff850d45217bbedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Tue, 5 Aug 2025 22:47:57 +0200 Subject: [PATCH] feat(editor): Update data store front-end (no-changelog) (#18000) --- .../frontend/@n8n/i18n/src/locales/en.json | 7 +- .../src/components/Projects/ProjectHeader.vue | 33 +++++++ .../features/dataStore/DataStoreView.test.ts | 14 +-- .../src/features/dataStore/DataStoreView.vue | 86 +++++++++++++++++-- .../components/AddDataStoreModal.vue | 2 +- .../components/DataStoreCard.test.ts | 16 ++-- .../dataStore/components/DataStoreCard.vue | 77 +++++++++++++---- .../src/features/dataStore/dataStore.store.ts | 54 +++++++++--- .../src/features/dataStore/datastore.api.ts | 68 ++++++++++++--- .../src/features/dataStore/datastore.types.ts | 14 ++- 10 files changed, 301 insertions(+), 70 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c0c47e2501..0d79fd6967 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -105,6 +105,7 @@ "generic.rename": "Rename", "generic.missing.permissions": "Missing permissions to perform this action", "generic.shortcutHint": "Or press", + "generic.unknownError": "An unknown error occurred", "generic.upgradeToEnterprise": "Upgrade to Enterprise", "generic.never": "Never", "about.aboutN8n": "About n8n", @@ -2817,10 +2818,14 @@ "dataStore.error.fetching": "Error loading data stores", "dataStore.add.title": "Create data store", "dataStore.add.description": "Set up a new data store to organize and manage your data.", - "dataStore.add.button.label": "Create data store", + "dataStore.add.button.label": "Create Data Store", "dataStore.add.input.name.label": "Data Store Name", "dataStore.add.input.name.placeholder": "Enter data store name", "dataStore.add.error": "Error creating data store", + "dataStore.delete.confirm.title": "Delete data store", + "dataStore.delete.confirm.message": "Are you sure you want to delete the data store \"{name}\"? This action cannot be undone.", + "dataStore.delete.error": "Error deleting data store", + "dataStore.rename.error": "Error renaming data store", "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 LDAP in the Docs", diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index a4481ab6ca..879f2ae6a6 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -21,6 +21,12 @@ import type { IUser } from 'n8n-workflow'; import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types'; import { useUIStore } from '@/stores/ui.store'; +export type CustomAction = { + id: string; + label: string; + disabled?: boolean; +}; + const route = useRoute(); const router = useRouter(); const i18n = useI18n(); @@ -31,8 +37,17 @@ const uiStore = useUIStore(); const projectPages = useProjectPages(); +type Props = { + customActions?: CustomAction[]; +}; + +const props = withDefaults(defineProps(), { + customActions: () => [], +}); + const emit = defineEmits<{ createFolder: []; + customActionSelected: [actionId: string, projectId: string]; }>(); const headerIcon = computed((): IconOrEmoji => { @@ -139,6 +154,17 @@ const menu = computed(() => { !getResourcePermissions(homeProject.value?.scopes).folder.create, }); } + + // Append custom actions + if (props.customActions?.length) { + props.customActions.forEach((customAction) => { + items.push({ + value: customAction.id, + label: customAction.label, + disabled: customAction.disabled ?? false, + }); + }); + } return items; }); @@ -240,6 +266,13 @@ const onSelect = (action: string) => { if (!homeProject.value) { return; } + + // Check if this is a custom action + if (!executableAction) { + emit('customActionSelected', action, homeProject.value.id); + return; + } + executableAction(homeProject.value.id); }; diff --git a/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.test.ts b/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.test.ts index 42920d5be2..9dc1b75e4b 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.test.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.test.ts @@ -100,9 +100,9 @@ const initialState = { const TEST_DATA_STORE: DataStoreResource = { id: '1', name: 'Test Data Store', - size: 1024, + sizeBytes: 1024, recordCount: 100, - columnCount: 5, + columns: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), resourceType: 'datastore', @@ -123,7 +123,7 @@ describe('DataStoreView', () => { // Mock dataStore store state dataStoreStore.dataStores = [TEST_DATA_STORE]; dataStoreStore.totalCount = 1; - dataStoreStore.loadDataStores = vi.fn().mockResolvedValue(undefined); + dataStoreStore.fetchDataStores = vi.fn().mockResolvedValue(undefined); projectsStore.getCurrentProjectId = vi.fn(() => 'test-project'); sourceControlStore.isProjectShared = vi.fn(() => false); @@ -134,7 +134,7 @@ describe('DataStoreView', () => { const { getByTestId } = renderComponent({ pinia }); await waitAllPromises(); - expect(dataStoreStore.loadDataStores).toHaveBeenCalledWith('test-project', 1, 25); + expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25); expect(getByTestId('resources-list-wrapper')).toBeInTheDocument(); }); @@ -147,7 +147,7 @@ describe('DataStoreView', () => { it('should handle initialization error', async () => { const error = new Error('Store Error'); - dataStoreStore.loadDataStores = vi.fn().mockRejectedValue(error); + dataStoreStore.fetchDataStores = vi.fn().mockRejectedValue(error); renderComponent({ pinia }); await waitAllPromises(); @@ -201,7 +201,7 @@ describe('DataStoreView', () => { await waitAllPromises(); // Clear the initial call - dataStoreStore.loadDataStores = vi.fn().mockClear(); + dataStoreStore.fetchDataStores = vi.fn().mockClear(); mockDebounce.callDebounced.mockClear(); // The component should be rendered and ready to handle pagination @@ -223,7 +223,7 @@ describe('DataStoreView', () => { await waitAllPromises(); // Initial call should use default page size of 25 - expect(dataStoreStore.loadDataStores).toHaveBeenCalledWith('test-project', 1, 25); + expect(dataStoreStore.fetchDataStores).toHaveBeenCalledWith('test-project', 1, 25); }); }); }); diff --git a/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.vue b/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.vue index 863d12b6bb..02185b66ab 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.vue @@ -1,5 +1,5 @@ @@ -132,6 +171,12 @@ const dataStoreRoute = computed(() => { } } +.card-name { + color: $custom-font-dark; + font-size: var(--font-size-m); + margin-bottom: var(--spacing-5xs); +} + .card-icon { flex-shrink: 0; color: var(--color-text-base); diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts index cf3914749d..c42f549571 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts @@ -2,7 +2,12 @@ import { defineStore } from 'pinia'; import { DATA_STORE_STORE } from '@/features/dataStore/constants'; import { ref } from 'vue'; import { useRootStore } from '@n8n/stores/useRootStore'; -import { fetchDataStores, createDataStore } from '@/features/dataStore/datastore.api'; +import { + fetchDataStoresApi, + createDataStoreApi, + deleteDataStoreApi, + updateDataStoreApi, +} from '@/features/dataStore/datastore.api'; import type { DataStoreEntity } from '@/features/dataStore/datastore.types'; export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { @@ -11,26 +16,55 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { const dataStores = ref([]); const totalCount = ref(0); - const loadDataStores = async (projectId: string, page: number, pageSize: number) => { - const response = await fetchDataStores(rootStore.restApiContext, projectId, { - page, - pageSize, + const fetchDataStores = async (projectId: string, page: number, pageSize: number) => { + const response = await fetchDataStoresApi(rootStore.restApiContext, projectId, { + skip: (page - 1) * pageSize, + take: pageSize, }); + console.log('Data stores fetched:', response); + dataStores.value = response.data; totalCount.value = response.count; }; - const createNewDataStore = async (name: string, projectId?: string) => { - const newStore = await createDataStore(rootStore.restApiContext, name, projectId); - dataStores.value.push(newStore.data); + const createDataStore = async (name: string, projectId?: string) => { + const newStore = await createDataStoreApi(rootStore.restApiContext, name, projectId); + 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 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; + }; + return { dataStores, totalCount, - loadDataStores, - createNewDataStore, + fetchDataStores, + createDataStore, + deleteDataStore, + updateDataStore, }; }); diff --git a/packages/frontend/editor-ui/src/features/dataStore/datastore.api.ts b/packages/frontend/editor-ui/src/features/dataStore/datastore.api.ts index 53b3ed7077..f82b137eb8 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/datastore.api.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/datastore.api.ts @@ -1,29 +1,71 @@ -import { getFullApiResponse } from '@n8n/rest-api-client'; +import { makeRestApiRequest } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; import { type DataStoreEntity } from '@/features/dataStore/datastore.types'; -export const fetchDataStores = async ( +export const fetchDataStoresApi = async ( context: IRestApiContext, projectId?: string, options?: { - page?: number; - pageSize?: number; + skip?: number; + take?: number; }, ) => { - return await getFullApiResponse(context, 'GET', '/data-stores', { - projectId, - options, - }); + const apiEndpoint = projectId ? `/projects/${projectId}/data-stores` : '/data-stores-global'; + return await makeRestApiRequest<{ count: number; data: DataStoreEntity[] }>( + context, + 'GET', + apiEndpoint, + { + ...options, + }, + ); }; -export const createDataStore = async ( +export const createDataStoreApi = async ( context: IRestApiContext, name: string, projectId?: string, ) => { - return await getFullApiResponse(context, 'POST', '/data-stores', { - name, - projectId, - }); + return await makeRestApiRequest( + context, + 'POST', + `/projects/${projectId}/data-stores`, + { + name, + columns: [], + }, + ); +}; + +export const deleteDataStoreApi = async ( + context: IRestApiContext, + dataStoreId: string, + projectId?: string, +) => { + return await makeRestApiRequest( + context, + 'DELETE', + `/projects/${projectId}/data-stores/${dataStoreId}`, + { + dataStoreId, + projectId, + }, + ); +}; + +export const updateDataStoreApi = async ( + context: IRestApiContext, + dataStoreId: string, + name: string, + projectId?: string, +) => { + return await makeRestApiRequest( + context, + 'PATCH', + `/projects/${projectId}/data-stores/${dataStoreId}`, + { + name, + }, + ); }; diff --git a/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts b/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts index a91fa97b55..f3ba0cb17b 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/datastore.types.ts @@ -1,10 +1,20 @@ +import type { ProjectSharingData } from 'n8n-workflow'; + export type DataStoreEntity = { id: string; name: string; - size: number; + sizeBytes: number; recordCount: number; - columnCount: number; + columns: DataStoreColumnEntity[]; createdAt: string; updatedAt: string; projectId?: string; + project?: ProjectSharingData; +}; + +export type DataStoreColumnEntity = { + id: string; + name: string; + type: string; + index: number; };