diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c9b4bcdec0..c0c47e2501 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2815,6 +2815,12 @@ "dataStore.sort.nameDesc": "Sort by name (Z-A)", "dataStore.search.placeholder": "Search", "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.input.name.label": "Data Store Name", + "dataStore.add.input.name.placeholder": "Enter data store name", + "dataStore.add.error": "Error creating 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/DynamicModalLoader.test.ts b/packages/frontend/editor-ui/src/components/DynamicModalLoader.test.ts new file mode 100644 index 0000000000..37fa85dbae --- /dev/null +++ b/packages/frontend/editor-ui/src/components/DynamicModalLoader.test.ts @@ -0,0 +1,313 @@ +import { createTestingPinia } from '@pinia/testing'; +import { defineComponent, nextTick } from 'vue'; +import { screen } from '@testing-library/vue'; +import DynamicModalLoader from '@/components/DynamicModalLoader.vue'; +import * as modalRegistry from '@/moduleInitializer/modalRegistry'; +import type { ModalDefinition } from '@/moduleInitializer/module.types'; +import { createComponentRenderer } from '@/__tests__/render'; +import { cleanupAppModals, createAppModals } from '@/__tests__/utils'; + +// Mock the modalRegistry module +vi.mock('@/moduleInitializer/modalRegistry', () => ({ + getAll: vi.fn(), + subscribe: vi.fn(), +})); + +const mockModalRegistry = vi.mocked(modalRegistry); + +const renderComponent = createComponentRenderer(DynamicModalLoader, { + global: { + stubs: { + ModalRoot: { + props: ['name', 'keepAlive'], + template: ` +
+ +
+ `, + }, + }, + }, +}); + +describe('DynamicModalLoader', () => { + const mockModalComponent = defineComponent({ + name: 'MockModal', + props: { + modalName: { type: String, required: true }, + active: { type: Boolean, required: true }, + open: { type: Boolean, required: true }, + activeId: { type: String, required: true }, + mode: { type: String, required: true }, + data: { type: Object, required: true }, + }, + template: '
Mock Modal
', + }); + + const mockAsyncModalComponent = vi.fn(async () => await Promise.resolve(mockModalComponent)); + + beforeEach(() => { + createAppModals(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanupAppModals(); + }); + + it('should render empty div when no modals are registered', () => { + mockModalRegistry.getAll.mockReturnValue(new Map()); + mockModalRegistry.subscribe.mockReturnValue(vi.fn()); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + + expect(container.firstChild).toBeInTheDocument(); + expect(screen.queryByTestId('modal-root')).not.toBeInTheDocument(); + }); + + it('should render ModalRoot components for registered modals', async () => { + const modalsMap = new Map([ + [ + 'testModal1', + { + key: 'testModal1', + component: mockModalComponent, + }, + ], + [ + 'testModal2', + { + key: 'testModal2', + component: mockModalComponent, + }, + ], + ]); + + mockModalRegistry.getAll.mockReturnValue(modalsMap); + mockModalRegistry.subscribe.mockReturnValue(vi.fn()); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + + const modalRoots = container.querySelectorAll('[data-testid="modal-root"]'); + expect(modalRoots).toHaveLength(2); + + const modalNames = Array.from(modalRoots).map((root) => root.getAttribute('data-modal-name')); + expect(modalNames).toContain('testModal1'); + expect(modalNames).toContain('testModal2'); + }); + + it('should handle async component factories', async () => { + const modalsMap = new Map([ + [ + 'asyncModal', + { + key: 'asyncModal', + component: mockAsyncModalComponent, + }, + ], + ]); + + mockModalRegistry.getAll.mockReturnValue(modalsMap); + mockModalRegistry.subscribe.mockReturnValue(vi.fn()); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + + const modalRoot = container.querySelector('[data-testid="modal-root"]'); + expect(modalRoot).toBeInTheDocument(); + expect(modalRoot?.getAttribute('data-modal-name')).toBe('asyncModal'); + expect(mockAsyncModalComponent).toHaveBeenCalled(); + }); + + it('should subscribe to modalRegistry changes on mount', () => { + mockModalRegistry.getAll.mockReturnValue(new Map()); + const mockUnsubscribe = vi.fn(); + mockModalRegistry.subscribe.mockReturnValue(mockUnsubscribe); + + renderComponent({ + pinia: createTestingPinia(), + }); + + expect(mockModalRegistry.subscribe).toHaveBeenCalledTimes(1); + expect(mockModalRegistry.subscribe).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should update modals when registry changes', async () => { + let subscribeCallback: ((modals: Map) => void) | undefined; + + // Initial empty registry + mockModalRegistry.getAll.mockReturnValue(new Map()); + mockModalRegistry.subscribe.mockImplementation((listener) => { + subscribeCallback = listener; + return vi.fn(); + }); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + + // Initially no modal roots + expect(container.querySelector('[data-testid="modal-root"]')).not.toBeInTheDocument(); + + // Simulate registry change + const newModalsMap = new Map([ + [ + 'newModal', + { + key: 'newModal', + component: mockModalComponent, + }, + ], + ]); + + mockModalRegistry.getAll.mockReturnValue(newModalsMap); + subscribeCallback?.(newModalsMap); + + await nextTick(); + + // Should now have one modal root + const modalRoot = container.querySelector('[data-testid="modal-root"]'); + expect(modalRoot).toBeInTheDocument(); + expect(modalRoot?.getAttribute('data-modal-name')).toBe('newModal'); + }); + + it('should unsubscribe from registry changes on unmount', () => { + mockModalRegistry.getAll.mockReturnValue(new Map()); + const mockUnsubscribe = vi.fn(); + mockModalRegistry.subscribe.mockReturnValue(mockUnsubscribe); + + const wrapper = renderComponent({ + pinia: createTestingPinia(), + }); + + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + wrapper.unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('should handle modals with different component types', async () => { + const regularComponent = defineComponent({ + name: 'RegularModal', + props: { + modalName: { type: String, required: true }, + active: { type: Boolean, required: true }, + open: { type: Boolean, required: true }, + activeId: { type: String, required: true }, + mode: { type: String, required: true }, + data: { type: Object, required: true }, + }, + template: '
Regular
', + }); + + const asyncComponent = async () => + await Promise.resolve( + defineComponent({ + name: 'AsyncModal', + props: { + modalName: { type: String, required: true }, + active: { type: Boolean, required: true }, + open: { type: Boolean, required: true }, + activeId: { type: String, required: true }, + mode: { type: String, required: true }, + data: { type: Object, required: true }, + }, + template: '
Async
', + }), + ); + + const modalsMap = new Map([ + ['regular', { key: 'regular', component: regularComponent }], + ['async', { key: 'async', component: asyncComponent }], + ]); + + mockModalRegistry.getAll.mockReturnValue(modalsMap); + mockModalRegistry.subscribe.mockReturnValue(vi.fn()); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + + const modalRoots = container.querySelectorAll('[data-testid="modal-root"]'); + expect(modalRoots).toHaveLength(2); + + // Both modals should be rendered + const modalNames = Array.from(modalRoots).map((root) => root.getAttribute('data-modal-name')); + expect(modalNames).toContain('regular'); + expect(modalNames).toContain('async'); + }); + + it('should call updateModals on mount', () => { + const modalsMap = new Map([ + ['testModal', { key: 'testModal', component: mockModalComponent }], + ]); + + mockModalRegistry.getAll.mockReturnValue(modalsMap); + mockModalRegistry.subscribe.mockReturnValue(vi.fn()); + + renderComponent({ + pinia: createTestingPinia(), + }); + + // getAll should be called during updateModals on mount + expect(mockModalRegistry.getAll).toHaveBeenCalledTimes(1); + }); + + it('should correctly identify async component factories', async () => { + // Test with regular component (not async) + const regularComponent = defineComponent({ + name: 'RegularComponent', + props: { + modalName: { type: String, required: true }, + active: { type: Boolean, required: true }, + open: { type: Boolean, required: true }, + activeId: { type: String, required: true }, + mode: { type: String, required: true }, + data: { type: Object, required: true }, + }, + template: '
Regular
', + }); + + // Test with async component factory + const asyncFactory = async () => await Promise.resolve(regularComponent); + + const modalsMap = new Map([ + ['regularModal', { key: 'regularModal', component: regularComponent }], + ['asyncModal', { key: 'asyncModal', component: asyncFactory }], + ]); + + mockModalRegistry.getAll.mockReturnValue(modalsMap); + mockModalRegistry.subscribe.mockReturnValue(vi.fn()); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + + // Both should render successfully + const modalRoots = container.querySelectorAll('[data-testid="modal-root"]'); + expect(modalRoots).toHaveLength(2); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/DynamicModalLoader.vue b/packages/frontend/editor-ui/src/components/DynamicModalLoader.vue new file mode 100644 index 0000000000..8e8fb8787e --- /dev/null +++ b/packages/frontend/editor-ui/src/components/DynamicModalLoader.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/frontend/editor-ui/src/components/Modals.vue b/packages/frontend/editor-ui/src/components/Modals.vue index f19b60431d..cd2d0f17c2 100644 --- a/packages/frontend/editor-ui/src/components/Modals.vue +++ b/packages/frontend/editor-ui/src/components/Modals.vue @@ -82,6 +82,7 @@ import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue'; import WorkflowDiffModal from '@/features/workflow-diff/WorkflowDiffModal.vue'; import type { EventBus } from '@n8n/utils/event-bus'; import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue'; +import DynamicModalLoader from './DynamicModalLoader.vue'; + + + 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 aff02bb8a0..42920d5be2 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.test.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.test.ts @@ -8,9 +8,7 @@ import { STORES } from '@n8n/stores'; import { createTestingPinia } from '@pinia/testing'; import { createRouter, createWebHistory } from 'vue-router'; import type { DataStoreResource } from '@/features/dataStore/types'; -import { fetchDataStores } from '@/features/dataStore/datastore.api'; - -vi.mock('@/features/dataStore/datastore.api'); +import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; vi.mock('@/composables/useProjectPages', () => ({ useProjectPages: vi.fn().mockReturnValue({ isOverviewSubPage: false, @@ -83,6 +81,7 @@ const router = createRouter({ let pinia: ReturnType; let projectsStore: ReturnType>; let sourceControlStore: ReturnType>; +let dataStoreStore: ReturnType>; const renderComponent = createComponentRenderer(DataStoreView, { global: { @@ -98,8 +97,6 @@ const initialState = { }, }; -const mockFetchDataStores = vi.mocked(fetchDataStores); - const TEST_DATA_STORE: DataStoreResource = { id: '1', name: 'Test Data Store', @@ -121,25 +118,23 @@ describe('DataStoreView', () => { pinia = createTestingPinia({ initialState }); projectsStore = mockedStore(useProjectsStore); sourceControlStore = mockedStore(useSourceControlStore); + dataStoreStore = mockedStore(useDataStoreStore); - mockFetchDataStores.mockResolvedValue({ - data: [TEST_DATA_STORE], - count: 1, - }); + // Mock dataStore store state + dataStoreStore.dataStores = [TEST_DATA_STORE]; + dataStoreStore.totalCount = 1; + dataStoreStore.loadDataStores = vi.fn().mockResolvedValue(undefined); projectsStore.getCurrentProjectId = vi.fn(() => 'test-project'); sourceControlStore.isProjectShared = vi.fn(() => false); }); describe('initialization', () => { - it('should initialize and fetch data stores', async () => { + it('should initialize and load data stores from store', async () => { const { getByTestId } = renderComponent({ pinia }); await waitAllPromises(); - expect(mockFetchDataStores).toHaveBeenCalledWith(expect.any(Object), 'test-project', { - page: 1, - pageSize: 25, - }); + expect(dataStoreStore.loadDataStores).toHaveBeenCalledWith('test-project', 1, 25); expect(getByTestId('resources-list-wrapper')).toBeInTheDocument(); }); @@ -151,8 +146,8 @@ describe('DataStoreView', () => { }); it('should handle initialization error', async () => { - const error = new Error('API Error'); - mockFetchDataStores.mockRejectedValue(error); + const error = new Error('Store Error'); + dataStoreStore.loadDataStores = vi.fn().mockRejectedValue(error); renderComponent({ pinia }); await waitAllPromises(); @@ -163,10 +158,8 @@ describe('DataStoreView', () => { describe('empty state', () => { beforeEach(() => { - mockFetchDataStores.mockResolvedValue({ - data: [], - count: 0, - }); + dataStoreStore.dataStores = []; + dataStoreStore.totalCount = 0; }); it('should show empty state when no data stores exist', async () => { @@ -208,7 +201,7 @@ describe('DataStoreView', () => { await waitAllPromises(); // Clear the initial call - mockFetchDataStores.mockClear(); + dataStoreStore.loadDataStores = vi.fn().mockClear(); mockDebounce.callDebounced.mockClear(); // The component should be rendered and ready to handle pagination @@ -219,23 +212,18 @@ describe('DataStoreView', () => { }); it('should update page size on pagination change', async () => { - mockFetchDataStores.mockResolvedValue({ - data: Array.from({ length: 20 }, (_, i) => ({ - ...TEST_DATA_STORE, - id: `${i + 1}`, - name: `Data Store ${i + 1}`, - })), - count: 20, - }); + dataStoreStore.dataStores = Array.from({ length: 20 }, (_, i) => ({ + ...TEST_DATA_STORE, + id: `${i + 1}`, + name: `Data Store ${i + 1}`, + })); + dataStoreStore.totalCount = 20; renderComponent({ pinia }); await waitAllPromises(); // Initial call should use default page size of 25 - expect(mockFetchDataStores).toHaveBeenCalledWith(expect.any(Object), 'test-project', { - page: 1, - pageSize: 25, - }); + expect(dataStoreStore.loadDataStores).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 465e28137c..863d12b6bb 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.vue +++ b/packages/frontend/editor-ui/src/features/dataStore/DataStoreView.vue @@ -10,19 +10,20 @@ import { computed, onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; import { ProjectTypes } from '@/types/projects.types'; import { useProjectsStore } from '@/stores/projects.store'; -import { fetchDataStores } from '@/features/dataStore/datastore.api'; -import { useRootStore } from '@n8n/stores/useRootStore'; import type { IUser, SortingAndPaginationUpdates, UserAction } from '@/Interface'; import type { DataStoreResource } from '@/features/dataStore/types'; import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { + ADD_DATA_STORE_MODAL_KEY, DATA_STORE_CARD_ACTIONS, DEFAULT_DATA_STORE_PAGE_SIZE, } from '@/features/dataStore/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'; const i18n = useI18n(); const route = useRoute(); @@ -31,18 +32,27 @@ const { callDebounced } = useDebounce(); const documentTitle = useDocumentTitle(); const toast = useToast(); +const dataStoreStore = useDataStoreStore(); const insightsStore = useInsightsStore(); const projectsStore = useProjectsStore(); -const rootStore = useRootStore(); const sourceControlStore = useSourceControlStore(); const loading = ref(true); -const dataStores = ref([]); -const totalCount = ref(0); const currentPage = ref(1); const pageSize = ref(DEFAULT_DATA_STORE_PAGE_SIZE); +const dataStoreResources = computed(() => + dataStoreStore.dataStores.map((ds) => { + return { + ...ds, + resourceType: 'datastore', + }; + }), +); + +const totalCount = computed(() => dataStoreStore.totalCount); + const currentProject = computed(() => projectsStore.currentProject); const projectName = computed(() => { @@ -91,15 +101,7 @@ const initialize = async () => { ? route.params.projectId[0] : route.params.projectId; try { - const response = await fetchDataStores(rootStore.restApiContext, projectId, { - page: currentPage.value, - pageSize: pageSize.value, - }); - dataStores.value = response.data.map((item) => ({ - ...item, - resourceType: 'datastore', - })); - totalCount.value = response.count; + await dataStoreStore.loadDataStores(projectId, currentPage.value, pageSize.value); } catch (error) { toast.showError(error, 'Error loading data stores'); } finally { @@ -119,6 +121,10 @@ const onPaginationUpdate = async (payload: SortingAndPaginationUpdates) => { } }; +const onAddModalClick = () => { + useUIStore().openModal(ADD_DATA_STORE_MODAL_KEY); +}; + onMounted(() => { documentTitle.set(i18n.baseText('dataStore.tab.label')); }); @@ -128,7 +134,7 @@ onMounted(() => { ref="layout" resource-key="dataStore" type="list-paginated" - :resources="dataStores" + :resources="dataStoreResources" :initialize="initialize" :type-props="{ itemSize: 80 }" :loading="loading" @@ -159,6 +165,7 @@ onMounted(() => { :description="emptyCalloutDescription" :button-text="emptyCalloutButtonText" button-type="secondary" + @click="onAddModalClick" />