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';
@@ -340,5 +341,8 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.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"
/>
diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/AddDataStoreModal.vue b/packages/frontend/editor-ui/src/features/dataStore/components/AddDataStoreModal.vue
new file mode 100644
index 0000000000..29944740ab
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/dataStore/components/AddDataStoreModal.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+ {{ i18n.baseText('dataStore.add.title') }}
+
+
+
+
{{ i18n.baseText('dataStore.add.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/dataStore/constants.ts b/packages/frontend/editor-ui/src/features/dataStore/constants.ts
index 5170048217..2be548d093 100644
--- a/packages/frontend/editor-ui/src/features/dataStore/constants.ts
+++ b/packages/frontend/editor-ui/src/features/dataStore/constants.ts
@@ -2,6 +2,7 @@
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 DEFAULT_DATA_STORE_PAGE_SIZE = 10;
@@ -10,3 +11,5 @@ export const DATA_STORE_CARD_ACTIONS = {
DELETE: 'delete',
CLEAR: 'clear',
};
+
+export const ADD_DATA_STORE_MODAL_KEY = 'addDataStoreModal';
diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts
new file mode 100644
index 0000000000..cf3914749d
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts
@@ -0,0 +1,36 @@
+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 type { DataStoreEntity } from '@/features/dataStore/datastore.types';
+
+export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
+ const rootStore = useRootStore();
+
+ 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,
+ });
+ 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);
+ totalCount.value += 1;
+ return newStore;
+ };
+
+ return {
+ dataStores,
+ totalCount,
+ loadDataStores,
+ createNewDataStore,
+ };
+});
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 c8026a0098..53b3ed7077 100644
--- a/packages/frontend/editor-ui/src/features/dataStore/datastore.api.ts
+++ b/packages/frontend/editor-ui/src/features/dataStore/datastore.api.ts
@@ -16,3 +16,14 @@ export const fetchDataStores = async (
options,
});
};
+
+export const createDataStore = async (
+ context: IRestApiContext,
+ name: string,
+ projectId?: string,
+) => {
+ return await getFullApiResponse(context, 'POST', '/data-stores', {
+ name,
+ projectId,
+ });
+};
diff --git a/packages/frontend/editor-ui/src/features/dataStore/module.descriptor.ts b/packages/frontend/editor-ui/src/features/dataStore/module.descriptor.ts
index 94a0a2e7ee..2febdcb85f 100644
--- a/packages/frontend/editor-ui/src/features/dataStore/module.descriptor.ts
+++ b/packages/frontend/editor-ui/src/features/dataStore/module.descriptor.ts
@@ -1,6 +1,7 @@
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,
@@ -16,6 +17,13 @@ export const DataStoreModule: FrontendModuleDescription = {
name: 'Data Store',
description: 'Manage and store data efficiently with the Data Store module.',
icon: 'database',
+ modals: [
+ {
+ key: ADD_DATA_STORE_MODAL_KEY,
+ component: async () => await import('./components/AddDataStoreModal.vue'),
+ initialState: { open: false },
+ },
+ ],
routes: [
{
name: DATA_STORE_VIEW,
diff --git a/packages/frontend/editor-ui/src/init.ts b/packages/frontend/editor-ui/src/init.ts
index 155a877b4f..3760356b59 100644
--- a/packages/frontend/editor-ui/src/init.ts
+++ b/packages/frontend/editor-ui/src/init.ts
@@ -25,6 +25,7 @@ import { useRBACStore } from '@/stores/rbac.store';
import {
registerModuleProjectTabs,
registerModuleResources,
+ registerModuleModals,
} from '@/moduleInitializer/moduleInitializer';
export const state = {
@@ -188,6 +189,7 @@ export async function initializeAuthenticatedFeatures(
// Initialize modules
registerModuleResources();
registerModuleProjectTabs();
+ registerModuleModals();
authenticatedFeaturesInitialized = true;
}
diff --git a/packages/frontend/editor-ui/src/moduleInitializer/modalRegistry.test.ts b/packages/frontend/editor-ui/src/moduleInitializer/modalRegistry.test.ts
new file mode 100644
index 0000000000..28b3e067ad
--- /dev/null
+++ b/packages/frontend/editor-ui/src/moduleInitializer/modalRegistry.test.ts
@@ -0,0 +1,298 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import type { ModalDefinition } from '@/moduleInitializer/module.types';
+import type { Component } from 'vue';
+import * as modalRegistry from '@/moduleInitializer/modalRegistry';
+
+describe('modalRegistry', () => {
+ const mockComponent1 = { name: 'TestModal1' } as Component;
+ const mockComponent2 = { name: 'TestModal2' } as Component;
+ const mockAsyncComponent = (): Component => {
+ return { name: 'AsyncTestModal3' } as Component;
+ };
+
+ const mockModal1: ModalDefinition = {
+ key: 'test-modal-1',
+ component: mockComponent1,
+ initialState: { open: false },
+ };
+
+ const mockModal2: ModalDefinition = {
+ key: 'test-modal-2',
+ component: mockComponent2,
+ };
+
+ const mockModal3: ModalDefinition = {
+ key: 'test-modal-3',
+ component: mockAsyncComponent,
+ };
+
+ beforeEach(() => {
+ // Clear all modals before each test
+ const keys = modalRegistry.getKeys();
+ keys.forEach((key) => modalRegistry.unregister(key));
+ });
+
+ describe('register', () => {
+ it('should register a new modal', () => {
+ modalRegistry.register(mockModal1);
+
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
+ expect(modalRegistry.get('test-modal-1')).toEqual(mockModal1);
+ });
+
+ it('should register multiple modals', () => {
+ modalRegistry.register(mockModal1);
+ modalRegistry.register(mockModal2);
+
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
+ expect(modalRegistry.has('test-modal-2')).toBe(true);
+ expect(modalRegistry.getKeys()).toHaveLength(2);
+ });
+
+ it('should warn and skip registration if modal key already exists', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ modalRegistry.register(mockModal1);
+ modalRegistry.register(mockModal1);
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Modal with key "test-modal-1" is already registered. Skipping.',
+ );
+ expect(modalRegistry.getKeys()).toHaveLength(1);
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should notify listeners when a modal is registered', () => {
+ const listener = vi.fn();
+ modalRegistry.subscribe(listener);
+
+ modalRegistry.register(mockModal1);
+
+ expect(listener).toHaveBeenCalledWith(expect.any(Map));
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('should register modal with async component', () => {
+ modalRegistry.register(mockModal3);
+
+ expect(modalRegistry.has('test-modal-3')).toBe(true);
+ expect(modalRegistry.get('test-modal-3')).toEqual(mockModal3);
+ });
+ });
+
+ describe('unregister', () => {
+ it('should unregister an existing modal', () => {
+ modalRegistry.register(mockModal1);
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
+
+ modalRegistry.unregister('test-modal-1');
+
+ expect(modalRegistry.has('test-modal-1')).toBe(false);
+ expect(modalRegistry.get('test-modal-1')).toBeUndefined();
+ });
+
+ it('should not notify listeners if modal does not exist', () => {
+ const listener = vi.fn();
+ modalRegistry.subscribe(listener);
+
+ modalRegistry.unregister('non-existent-modal');
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('should notify listeners when a modal is unregistered', () => {
+ modalRegistry.register(mockModal1);
+ const listener = vi.fn();
+ modalRegistry.subscribe(listener);
+
+ modalRegistry.unregister('test-modal-1');
+
+ expect(listener).toHaveBeenCalledWith(expect.any(Map));
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('get', () => {
+ it('should return modal definition for existing key', () => {
+ modalRegistry.register(mockModal1);
+
+ const result = modalRegistry.get('test-modal-1');
+
+ expect(result).toEqual(mockModal1);
+ });
+
+ it('should return undefined for non-existent key', () => {
+ const result = modalRegistry.get('non-existent-modal');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getAll', () => {
+ it('should return empty map when no modals are registered', () => {
+ const result = modalRegistry.getAll();
+
+ expect(result).toBeInstanceOf(Map);
+ expect(result.size).toBe(0);
+ });
+
+ it('should return all registered modals', () => {
+ modalRegistry.register(mockModal1);
+ modalRegistry.register(mockModal2);
+
+ const result = modalRegistry.getAll();
+
+ expect(result).toBeInstanceOf(Map);
+ expect(result.size).toBe(2);
+ expect(result.get('test-modal-1')).toEqual(mockModal1);
+ expect(result.get('test-modal-2')).toEqual(mockModal2);
+ });
+
+ it('should return a copy of the internal map', () => {
+ modalRegistry.register(mockModal1);
+
+ const result1 = modalRegistry.getAll();
+ const result2 = modalRegistry.getAll();
+
+ expect(result1).not.toBe(result2);
+ expect(result1).toEqual(result2);
+ });
+ });
+
+ describe('getKeys', () => {
+ it('should return empty array when no modals are registered', () => {
+ const result = modalRegistry.getKeys();
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return array of all modal keys', () => {
+ modalRegistry.register(mockModal1);
+ modalRegistry.register(mockModal2);
+
+ const result = modalRegistry.getKeys();
+
+ expect(result).toEqual(['test-modal-1', 'test-modal-2']);
+ });
+ });
+
+ describe('has', () => {
+ it('should return true for existing modal key', () => {
+ modalRegistry.register(mockModal1);
+
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
+ });
+
+ it('should return false for non-existent modal key', () => {
+ expect(modalRegistry.has('non-existent-modal')).toBe(false);
+ });
+ });
+
+ describe('subscribe', () => {
+ it('should call listener when modals are updated', () => {
+ const listener = vi.fn();
+ modalRegistry.subscribe(listener);
+
+ modalRegistry.register(mockModal1);
+
+ expect(listener).toHaveBeenCalledWith(expect.any(Map));
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ const calledMap = listener.mock.calls[0]?.[0] as Map;
+ expect(calledMap?.get('test-modal-1')).toEqual(mockModal1);
+ });
+
+ it('should return unsubscribe function', () => {
+ const listener = vi.fn();
+ const unsubscribe = modalRegistry.subscribe(listener);
+
+ expect(typeof unsubscribe).toBe('function');
+
+ modalRegistry.register(mockModal1);
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ unsubscribe();
+ modalRegistry.register(mockModal2);
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('should support multiple listeners', () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ modalRegistry.subscribe(listener1);
+ modalRegistry.subscribe(listener2);
+
+ modalRegistry.register(mockModal1);
+
+ expect(listener1).toHaveBeenCalledTimes(1);
+ expect(listener2).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call listeners with current state of modals', () => {
+ modalRegistry.register(mockModal1);
+ modalRegistry.register(mockModal2);
+
+ const listener = vi.fn();
+ modalRegistry.subscribe(listener);
+
+ modalRegistry.register(mockModal3);
+
+ const calledMap = listener.mock.calls[0]?.[0] as Map;
+ expect(calledMap?.size).toBe(3);
+ expect(calledMap?.has('test-modal-1')).toBe(true);
+ expect(calledMap?.has('test-modal-2')).toBe(true);
+ expect(calledMap?.has('test-modal-3')).toBe(true);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty string key', () => {
+ const emptyKeyModal: ModalDefinition = {
+ key: '',
+ component: { name: 'EmptyKeyModal' } as Component,
+ };
+
+ modalRegistry.register(emptyKeyModal);
+
+ expect(modalRegistry.has('')).toBe(true);
+ expect(modalRegistry.get('')).toEqual(emptyKeyModal);
+ });
+
+ it('should handle special characters in key', () => {
+ const specialKeyModal: ModalDefinition = {
+ key: 'modal-with-$special_ch@rs!',
+ component: { name: 'SpecialModal' } as Component,
+ };
+
+ modalRegistry.register(specialKeyModal);
+
+ expect(modalRegistry.has('modal-with-$special_ch@rs!')).toBe(true);
+ expect(modalRegistry.get('modal-with-$special_ch@rs!')).toEqual(specialKeyModal);
+ });
+
+ it('should handle modal without initialState', () => {
+ const modalWithoutState: ModalDefinition = {
+ key: 'no-state-modal',
+ component: { name: 'NoStateModal' } as Component,
+ };
+
+ modalRegistry.register(modalWithoutState);
+
+ const retrieved = modalRegistry.get('no-state-modal');
+ expect(retrieved).toEqual(modalWithoutState);
+ expect(retrieved?.initialState).toBeUndefined();
+ });
+
+ it('should maintain registration order in getKeys', () => {
+ modalRegistry.register(mockModal2);
+ modalRegistry.register(mockModal1);
+ modalRegistry.register(mockModal3);
+
+ const keys = modalRegistry.getKeys();
+
+ expect(keys).toEqual(['test-modal-2', 'test-modal-1', 'test-modal-3']);
+ });
+ });
+});
diff --git a/packages/frontend/editor-ui/src/moduleInitializer/modalRegistry.ts b/packages/frontend/editor-ui/src/moduleInitializer/modalRegistry.ts
new file mode 100644
index 0000000000..db992b5ead
--- /dev/null
+++ b/packages/frontend/editor-ui/src/moduleInitializer/modalRegistry.ts
@@ -0,0 +1,47 @@
+import type { ModalDefinition } from '@/moduleInitializer/module.types';
+
+const modals = new Map();
+const listeners = new Set<(modals: Map) => void>();
+
+export function getAll(): Map {
+ return new Map(modals);
+}
+
+function notifyListeners(): void {
+ listeners.forEach((listener) => listener(getAll()));
+}
+
+export function register(modal: ModalDefinition): void {
+ if (modals.has(modal.key)) {
+ console.warn(`Modal with key "${modal.key}" is already registered. Skipping.`);
+ return;
+ }
+
+ modals.set(modal.key, modal);
+ notifyListeners();
+}
+
+export function unregister(key: string): void {
+ if (modals.delete(key)) {
+ notifyListeners();
+ }
+}
+
+export function get(key: string): ModalDefinition | undefined {
+ return modals.get(key);
+}
+
+export function getKeys(): string[] {
+ return Array.from(modals.keys());
+}
+
+export function has(key: string): boolean {
+ return modals.has(key);
+}
+
+export function subscribe(listener: (modals: Map) => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
diff --git a/packages/frontend/editor-ui/src/moduleInitializer/module.types.ts b/packages/frontend/editor-ui/src/moduleInitializer/module.types.ts
index 9319295ba4..c683036856 100644
--- a/packages/frontend/editor-ui/src/moduleInitializer/module.types.ts
+++ b/packages/frontend/editor-ui/src/moduleInitializer/module.types.ts
@@ -1,5 +1,13 @@
+import type { ModalState } from '@/Interface';
import type { DynamicTabOptions } from '@/utils/modules/tabUtils';
import type { RouteRecordRaw } from 'vue-router';
+import type { Component } from 'vue/dist/vue.js';
+
+export type ModalDefinition = {
+ key: string;
+ component: Component | (() => Promise);
+ initialState?: ModalState;
+};
export type ResourceMetadata = {
key: string;
@@ -19,4 +27,5 @@ export type FrontendModuleDescription = {
shared?: DynamicTabOptions[];
};
resources?: ResourceMetadata[];
+ modals?: ModalDefinition[];
};
diff --git a/packages/frontend/editor-ui/src/moduleInitializer/moduleInitializer.ts b/packages/frontend/editor-ui/src/moduleInitializer/moduleInitializer.ts
index f5338b2544..756d97eccc 100644
--- a/packages/frontend/editor-ui/src/moduleInitializer/moduleInitializer.ts
+++ b/packages/frontend/editor-ui/src/moduleInitializer/moduleInitializer.ts
@@ -6,6 +6,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { InsightsModule } from '../features/insights/module.descriptor';
import type { FrontendModuleDescription } from '@/moduleInitializer/module.types';
+import * as modalRegistry from '@/moduleInitializer/modalRegistry';
/**
* Hard-coding modules list until we have a dynamic way to load modules.
@@ -54,6 +55,19 @@ const checkModuleAvailability = (options: any) => {
return useSettingsStore().isModuleActive(options.to.meta.moduleName);
};
+/**
+ * Initialize module modals, done in init.ts
+ */
+export const registerModuleModals = () => {
+ modules.forEach((module) => {
+ module.modals?.forEach((modalDef) => {
+ modalRegistry.register(modalDef);
+ });
+ });
+ // Subscribe to modal registry changes
+ useUIStore().initializeModalsFromRegistry();
+};
+
/**
* Initialize module routes, done in main.ts
*/
diff --git a/packages/frontend/editor-ui/src/stores/ui.store.ts b/packages/frontend/editor-ui/src/stores/ui.store.ts
index ed13f82efc..2ec2efd853 100644
--- a/packages/frontend/editor-ui/src/stores/ui.store.ts
+++ b/packages/frontend/editor-ui/src/stores/ui.store.ts
@@ -70,6 +70,7 @@ import { useLocalStorage, useMediaQuery } from '@vueuse/core';
import type { EventBus } from '@n8n/utils/event-bus';
import type { ProjectSharingData } from '@/types/projects.types';
import identity from 'lodash/identity';
+import * as modalRegistry from '@/moduleInitializer/modalRegistry';
let savedTheme: ThemeOption = 'system';
@@ -577,6 +578,54 @@ export const useUIStore = defineStore(STORES.UI, () => {
options.banners.forEach(pushBannerToStack);
};
+ /**
+ * Register a modal dynamically
+ */
+ const registerModal = (modalKey: string, initialState?: ModalState) => {
+ if (!modalsById.value[modalKey]) {
+ modalsById.value[modalKey] = initialState || { open: false };
+ }
+ };
+
+ /**
+ * Unregister a modal
+ */
+ const unregisterModal = (modalKey: string) => {
+ if (modalsById.value[modalKey]) {
+ // Close the modal if it's open
+ if (modalsById.value[modalKey].open) {
+ closeModal(modalKey);
+ }
+ delete modalsById.value[modalKey];
+ }
+ };
+
+ /**
+ * Initialize modals from the registry
+ */
+ const initializeModalsFromRegistry = () => {
+ modalRegistry.getAll().forEach((modalDef, key) => {
+ registerModal(key, modalDef.initialState);
+ });
+ };
+
+ // Subscribe to registry changes
+ const unsubscribeFromModalRegistry = modalRegistry.subscribe((modals) => {
+ // Add new modals that aren't registered yet
+ modals.forEach((modalDef, key) => {
+ if (!modalsById.value[key]) {
+ registerModal(key, modalDef.initialState);
+ }
+ });
+ });
+
+ /**
+ * Clean up modal registry subscription
+ */
+ const cleanup = () => {
+ unsubscribeFromModalRegistry();
+ };
+
return {
appGridDimensions,
appliedTheme,
@@ -633,6 +682,10 @@ export const useUIStore = defineStore(STORES.UI, () => {
initialize,
moduleTabs,
registerCustomTabs,
+ registerModal,
+ unregisterModal,
+ initializeModalsFromRegistry,
+ cleanup,
};
});
@@ -649,7 +702,7 @@ export const listenForModalChanges = (opts: {
return store.$onAction((result) => {
const { name, after, args } = result;
- after(async () => {
+ after(() => {
if (!listeningForActions.includes(name)) {
return;
}