mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Allow front-end modules to register modals (no-changelog) (#17885)
This commit is contained in:
committed by
GitHub
parent
c425444a7c
commit
a4f75101c7
@@ -2815,6 +2815,12 @@
|
|||||||
"dataStore.sort.nameDesc": "Sort by name (Z-A)",
|
"dataStore.sort.nameDesc": "Sort by name (Z-A)",
|
||||||
"dataStore.search.placeholder": "Search",
|
"dataStore.search.placeholder": "Search",
|
||||||
"dataStore.error.fetching": "Error loading data stores",
|
"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": "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.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>",
|
"settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<div data-testid="modal-root" :data-modal-name="name">
|
||||||
|
<slot
|
||||||
|
:modalName="name"
|
||||||
|
:active="true"
|
||||||
|
:open="true"
|
||||||
|
:activeId="'test-id'"
|
||||||
|
:mode="'edit'"
|
||||||
|
:data="{ test: 'value' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<div data-testid="mock-modal" :data-modal-name="modalName">Mock Modal</div>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string, ModalDefinition>([
|
||||||
|
[
|
||||||
|
'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<string, ModalDefinition>([
|
||||||
|
[
|
||||||
|
'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<string, ModalDefinition>) => 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<string, ModalDefinition>([
|
||||||
|
[
|
||||||
|
'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: '<div data-testid="regular-modal">Regular</div>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<div data-testid="async-modal">Async</div>',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalsMap = new Map<string, ModalDefinition>([
|
||||||
|
['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<string, ModalDefinition>([
|
||||||
|
['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: '<div>Regular</div>',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with async component factory
|
||||||
|
const asyncFactory = async () => await Promise.resolve(regularComponent);
|
||||||
|
|
||||||
|
const modalsMap = new Map<string, ModalDefinition>([
|
||||||
|
['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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, defineAsyncComponent } from 'vue';
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
import * as modalRegistry from '@/moduleInitializer/modalRegistry';
|
||||||
|
import ModalRoot from '@/components/ModalRoot.vue';
|
||||||
|
|
||||||
|
// Keep track of registered modals
|
||||||
|
const registeredModals = ref<
|
||||||
|
Array<{
|
||||||
|
key: string;
|
||||||
|
component: Component;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// Type guard to check if component is an async component factory
|
||||||
|
const isAsyncComponentFactory = (
|
||||||
|
component: Component | (() => Promise<Component>),
|
||||||
|
): component is () => Promise<Component> => {
|
||||||
|
return typeof component === 'function';
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateModals = () => {
|
||||||
|
const modals: Array<{
|
||||||
|
key: string;
|
||||||
|
component: Component;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
modalRegistry.getAll().forEach((modalDef, key) => {
|
||||||
|
// Create async component wrapper if it's a function
|
||||||
|
const component = isAsyncComponentFactory(modalDef.component)
|
||||||
|
? defineAsyncComponent(modalDef.component)
|
||||||
|
: modalDef.component;
|
||||||
|
|
||||||
|
modals.push({ key, component });
|
||||||
|
});
|
||||||
|
|
||||||
|
registeredModals.value = modals;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to registry changes
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateModals(); // Initial load
|
||||||
|
unsubscribe = modalRegistry.subscribe(() => {
|
||||||
|
updateModals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unsubscribe?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<template v-for="modal in registeredModals" :key="modal.key">
|
||||||
|
<ModalRoot :name="modal.key">
|
||||||
|
<template #default="{ modalName, active, open, activeId, mode, data }">
|
||||||
|
<component
|
||||||
|
:is="modal.component"
|
||||||
|
:modal-name="modalName"
|
||||||
|
:active="active"
|
||||||
|
:open="open"
|
||||||
|
:active-id="activeId"
|
||||||
|
:mode="mode"
|
||||||
|
:data="data"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -82,6 +82,7 @@ import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue';
|
|||||||
import WorkflowDiffModal from '@/features/workflow-diff/WorkflowDiffModal.vue';
|
import WorkflowDiffModal from '@/features/workflow-diff/WorkflowDiffModal.vue';
|
||||||
import type { EventBus } from '@n8n/utils/event-bus';
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||||
|
import DynamicModalLoader from './DynamicModalLoader.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -340,5 +341,8 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
|||||||
<WhatsNewModal :modal-name="modalName" :data="data" />
|
<WhatsNewModal :modal-name="modalName" :data="data" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<!-- Dynamic modals from modules -->
|
||||||
|
<DynamicModalLoader />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import { STORES } from '@n8n/stores';
|
|||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import type { DataStoreResource } from '@/features/dataStore/types';
|
import type { DataStoreResource } from '@/features/dataStore/types';
|
||||||
import { fetchDataStores } from '@/features/dataStore/datastore.api';
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
|
|
||||||
vi.mock('@/features/dataStore/datastore.api');
|
|
||||||
vi.mock('@/composables/useProjectPages', () => ({
|
vi.mock('@/composables/useProjectPages', () => ({
|
||||||
useProjectPages: vi.fn().mockReturnValue({
|
useProjectPages: vi.fn().mockReturnValue({
|
||||||
isOverviewSubPage: false,
|
isOverviewSubPage: false,
|
||||||
@@ -83,6 +81,7 @@ const router = createRouter({
|
|||||||
let pinia: ReturnType<typeof createTestingPinia>;
|
let pinia: ReturnType<typeof createTestingPinia>;
|
||||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||||
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
||||||
|
let dataStoreStore: ReturnType<typeof mockedStore<typeof useDataStoreStore>>;
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(DataStoreView, {
|
const renderComponent = createComponentRenderer(DataStoreView, {
|
||||||
global: {
|
global: {
|
||||||
@@ -98,8 +97,6 @@ const initialState = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockFetchDataStores = vi.mocked(fetchDataStores);
|
|
||||||
|
|
||||||
const TEST_DATA_STORE: DataStoreResource = {
|
const TEST_DATA_STORE: DataStoreResource = {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Test Data Store',
|
name: 'Test Data Store',
|
||||||
@@ -121,25 +118,23 @@ describe('DataStoreView', () => {
|
|||||||
pinia = createTestingPinia({ initialState });
|
pinia = createTestingPinia({ initialState });
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
sourceControlStore = mockedStore(useSourceControlStore);
|
sourceControlStore = mockedStore(useSourceControlStore);
|
||||||
|
dataStoreStore = mockedStore(useDataStoreStore);
|
||||||
|
|
||||||
mockFetchDataStores.mockResolvedValue({
|
// Mock dataStore store state
|
||||||
data: [TEST_DATA_STORE],
|
dataStoreStore.dataStores = [TEST_DATA_STORE];
|
||||||
count: 1,
|
dataStoreStore.totalCount = 1;
|
||||||
});
|
dataStoreStore.loadDataStores = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
projectsStore.getCurrentProjectId = vi.fn(() => 'test-project');
|
projectsStore.getCurrentProjectId = vi.fn(() => 'test-project');
|
||||||
sourceControlStore.isProjectShared = vi.fn(() => false);
|
sourceControlStore.isProjectShared = vi.fn(() => false);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initialization', () => {
|
describe('initialization', () => {
|
||||||
it('should initialize and fetch data stores', async () => {
|
it('should initialize and load data stores from store', async () => {
|
||||||
const { getByTestId } = renderComponent({ pinia });
|
const { getByTestId } = renderComponent({ pinia });
|
||||||
await waitAllPromises();
|
await waitAllPromises();
|
||||||
|
|
||||||
expect(mockFetchDataStores).toHaveBeenCalledWith(expect.any(Object), 'test-project', {
|
expect(dataStoreStore.loadDataStores).toHaveBeenCalledWith('test-project', 1, 25);
|
||||||
page: 1,
|
|
||||||
pageSize: 25,
|
|
||||||
});
|
|
||||||
expect(getByTestId('resources-list-wrapper')).toBeInTheDocument();
|
expect(getByTestId('resources-list-wrapper')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,8 +146,8 @@ describe('DataStoreView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle initialization error', async () => {
|
it('should handle initialization error', async () => {
|
||||||
const error = new Error('API Error');
|
const error = new Error('Store Error');
|
||||||
mockFetchDataStores.mockRejectedValue(error);
|
dataStoreStore.loadDataStores = vi.fn().mockRejectedValue(error);
|
||||||
|
|
||||||
renderComponent({ pinia });
|
renderComponent({ pinia });
|
||||||
await waitAllPromises();
|
await waitAllPromises();
|
||||||
@@ -163,10 +158,8 @@ describe('DataStoreView', () => {
|
|||||||
|
|
||||||
describe('empty state', () => {
|
describe('empty state', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetchDataStores.mockResolvedValue({
|
dataStoreStore.dataStores = [];
|
||||||
data: [],
|
dataStoreStore.totalCount = 0;
|
||||||
count: 0,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show empty state when no data stores exist', async () => {
|
it('should show empty state when no data stores exist', async () => {
|
||||||
@@ -208,7 +201,7 @@ describe('DataStoreView', () => {
|
|||||||
await waitAllPromises();
|
await waitAllPromises();
|
||||||
|
|
||||||
// Clear the initial call
|
// Clear the initial call
|
||||||
mockFetchDataStores.mockClear();
|
dataStoreStore.loadDataStores = vi.fn().mockClear();
|
||||||
mockDebounce.callDebounced.mockClear();
|
mockDebounce.callDebounced.mockClear();
|
||||||
|
|
||||||
// The component should be rendered and ready to handle pagination
|
// 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 () => {
|
it('should update page size on pagination change', async () => {
|
||||||
mockFetchDataStores.mockResolvedValue({
|
dataStoreStore.dataStores = Array.from({ length: 20 }, (_, i) => ({
|
||||||
data: Array.from({ length: 20 }, (_, i) => ({
|
|
||||||
...TEST_DATA_STORE,
|
...TEST_DATA_STORE,
|
||||||
id: `${i + 1}`,
|
id: `${i + 1}`,
|
||||||
name: `Data Store ${i + 1}`,
|
name: `Data Store ${i + 1}`,
|
||||||
})),
|
}));
|
||||||
count: 20,
|
dataStoreStore.totalCount = 20;
|
||||||
});
|
|
||||||
|
|
||||||
renderComponent({ pinia });
|
renderComponent({ pinia });
|
||||||
await waitAllPromises();
|
await waitAllPromises();
|
||||||
|
|
||||||
// Initial call should use default page size of 25
|
// Initial call should use default page size of 25
|
||||||
expect(mockFetchDataStores).toHaveBeenCalledWith(expect.any(Object), 'test-project', {
|
expect(dataStoreStore.loadDataStores).toHaveBeenCalledWith('test-project', 1, 25);
|
||||||
page: 1,
|
|
||||||
pageSize: 25,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,19 +10,20 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
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 { IUser, SortingAndPaginationUpdates, UserAction } from '@/Interface';
|
||||||
import type { DataStoreResource } from '@/features/dataStore/types';
|
import type { DataStoreResource } from '@/features/dataStore/types';
|
||||||
import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue';
|
import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import {
|
import {
|
||||||
|
ADD_DATA_STORE_MODAL_KEY,
|
||||||
DATA_STORE_CARD_ACTIONS,
|
DATA_STORE_CARD_ACTIONS,
|
||||||
DEFAULT_DATA_STORE_PAGE_SIZE,
|
DEFAULT_DATA_STORE_PAGE_SIZE,
|
||||||
} from '@/features/dataStore/constants';
|
} from '@/features/dataStore/constants';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -31,18 +32,27 @@ const { callDebounced } = useDebounce();
|
|||||||
const documentTitle = useDocumentTitle();
|
const documentTitle = useDocumentTitle();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
const insightsStore = useInsightsStore();
|
const insightsStore = useInsightsStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const rootStore = useRootStore();
|
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const dataStores = ref<DataStoreResource[]>([]);
|
|
||||||
const totalCount = ref(0);
|
|
||||||
|
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = ref(DEFAULT_DATA_STORE_PAGE_SIZE);
|
const pageSize = ref(DEFAULT_DATA_STORE_PAGE_SIZE);
|
||||||
|
|
||||||
|
const dataStoreResources = computed<DataStoreResource[]>(() =>
|
||||||
|
dataStoreStore.dataStores.map((ds) => {
|
||||||
|
return {
|
||||||
|
...ds,
|
||||||
|
resourceType: 'datastore',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCount = computed(() => dataStoreStore.totalCount);
|
||||||
|
|
||||||
const currentProject = computed(() => projectsStore.currentProject);
|
const currentProject = computed(() => projectsStore.currentProject);
|
||||||
|
|
||||||
const projectName = computed(() => {
|
const projectName = computed(() => {
|
||||||
@@ -91,15 +101,7 @@ const initialize = async () => {
|
|||||||
? route.params.projectId[0]
|
? route.params.projectId[0]
|
||||||
: route.params.projectId;
|
: route.params.projectId;
|
||||||
try {
|
try {
|
||||||
const response = await fetchDataStores(rootStore.restApiContext, projectId, {
|
await dataStoreStore.loadDataStores(projectId, currentPage.value, pageSize.value);
|
||||||
page: currentPage.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
});
|
|
||||||
dataStores.value = response.data.map((item) => ({
|
|
||||||
...item,
|
|
||||||
resourceType: 'datastore',
|
|
||||||
}));
|
|
||||||
totalCount.value = response.count;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, 'Error loading data stores');
|
toast.showError(error, 'Error loading data stores');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -119,6 +121,10 @@ const onPaginationUpdate = async (payload: SortingAndPaginationUpdates) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddModalClick = () => {
|
||||||
|
useUIStore().openModal(ADD_DATA_STORE_MODAL_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
documentTitle.set(i18n.baseText('dataStore.tab.label'));
|
documentTitle.set(i18n.baseText('dataStore.tab.label'));
|
||||||
});
|
});
|
||||||
@@ -128,7 +134,7 @@ onMounted(() => {
|
|||||||
ref="layout"
|
ref="layout"
|
||||||
resource-key="dataStore"
|
resource-key="dataStore"
|
||||||
type="list-paginated"
|
type="list-paginated"
|
||||||
:resources="dataStores"
|
:resources="dataStoreResources"
|
||||||
:initialize="initialize"
|
:initialize="initialize"
|
||||||
:type-props="{ itemSize: 80 }"
|
:type-props="{ itemSize: 80 }"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -159,6 +165,7 @@ onMounted(() => {
|
|||||||
:description="emptyCalloutDescription"
|
:description="emptyCalloutDescription"
|
||||||
:button-text="emptyCalloutButtonText"
|
:button-text="emptyCalloutButtonText"
|
||||||
button-type="secondary"
|
button-type="secondary"
|
||||||
|
@click="onAddModalClick"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item="{ item: data }">
|
<template #item="{ item: data }">
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useDataStoreStore } from '../dataStore.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
modalName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const dataStoreName = ref('');
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.value?.focus();
|
||||||
|
inputRef.value?.select();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await dataStoreStore.createNewDataStore(dataStoreName.value, route.params.projectId as string);
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('dataStore.add.error'));
|
||||||
|
} finally {
|
||||||
|
dataStoreName.value = '';
|
||||||
|
uiStore.closeModal(props.modalName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :name="props.modalName" :center="true" width="540px">
|
||||||
|
<template #header>
|
||||||
|
<h2>{{ i18n.baseText('dataStore.add.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div :class="$style.content">
|
||||||
|
<p>{{ i18n.baseText('dataStore.add.description') }}</p>
|
||||||
|
<n8n-input-label
|
||||||
|
:label="i18n.baseText('dataStore.add.input.name.label')"
|
||||||
|
:required="true"
|
||||||
|
input-name="dataStoreName"
|
||||||
|
>
|
||||||
|
<n8n-input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="dataStoreName"
|
||||||
|
type="text"
|
||||||
|
:placeholder="i18n.baseText('dataStore.add.input.name.placeholder')"
|
||||||
|
data-test-id="data-store-name-input"
|
||||||
|
name="dataStoreName"
|
||||||
|
@keyup.enter="onSubmit"
|
||||||
|
/>
|
||||||
|
</n8n-input-label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer="{ close }">
|
||||||
|
<div :class="$style.footer">
|
||||||
|
<n8n-button
|
||||||
|
:disabled="!dataStoreName"
|
||||||
|
:label="i18n.baseText('dataStore.add.button.label')"
|
||||||
|
data-test-id="confirm-add-data-store-button"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
<n8n-button
|
||||||
|
type="secondary"
|
||||||
|
:label="i18n.baseText('generic.cancel')"
|
||||||
|
data-test-id="cancel-add-data-store-button"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--spacing-l);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
export const DATA_STORE_VIEW = 'data-stores';
|
export const DATA_STORE_VIEW = 'data-stores';
|
||||||
export const PROJECT_DATA_STORES = 'project-data-stores';
|
export const PROJECT_DATA_STORES = 'project-data-stores';
|
||||||
export const DATA_STORE_DETAILS = 'data-store-details';
|
export const DATA_STORE_DETAILS = 'data-store-details';
|
||||||
|
export const DATA_STORE_STORE = 'dataStoreStore';
|
||||||
|
|
||||||
export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
|
export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
|
||||||
|
|
||||||
@@ -10,3 +11,5 @@ export const DATA_STORE_CARD_ACTIONS = {
|
|||||||
DELETE: 'delete',
|
DELETE: 'delete',
|
||||||
CLEAR: 'clear',
|
CLEAR: 'clear',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ADD_DATA_STORE_MODAL_KEY = 'addDataStoreModal';
|
||||||
|
|||||||
@@ -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<DataStoreEntity[]>([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -16,3 +16,14 @@ export const fetchDataStores = async (
|
|||||||
options,
|
options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createDataStore = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
name: string,
|
||||||
|
projectId?: string,
|
||||||
|
) => {
|
||||||
|
return await getFullApiResponse<DataStoreEntity>(context, 'POST', '/data-stores', {
|
||||||
|
name,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { type FrontendModuleDescription } from '@/moduleInitializer/module.types';
|
import { type FrontendModuleDescription } from '@/moduleInitializer/module.types';
|
||||||
import {
|
import {
|
||||||
|
ADD_DATA_STORE_MODAL_KEY,
|
||||||
DATA_STORE_DETAILS,
|
DATA_STORE_DETAILS,
|
||||||
DATA_STORE_VIEW,
|
DATA_STORE_VIEW,
|
||||||
PROJECT_DATA_STORES,
|
PROJECT_DATA_STORES,
|
||||||
@@ -16,6 +17,13 @@ export const DataStoreModule: FrontendModuleDescription = {
|
|||||||
name: 'Data Store',
|
name: 'Data Store',
|
||||||
description: 'Manage and store data efficiently with the Data Store module.',
|
description: 'Manage and store data efficiently with the Data Store module.',
|
||||||
icon: 'database',
|
icon: 'database',
|
||||||
|
modals: [
|
||||||
|
{
|
||||||
|
key: ADD_DATA_STORE_MODAL_KEY,
|
||||||
|
component: async () => await import('./components/AddDataStoreModal.vue'),
|
||||||
|
initialState: { open: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
name: DATA_STORE_VIEW,
|
name: DATA_STORE_VIEW,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useRBACStore } from '@/stores/rbac.store';
|
|||||||
import {
|
import {
|
||||||
registerModuleProjectTabs,
|
registerModuleProjectTabs,
|
||||||
registerModuleResources,
|
registerModuleResources,
|
||||||
|
registerModuleModals,
|
||||||
} from '@/moduleInitializer/moduleInitializer';
|
} from '@/moduleInitializer/moduleInitializer';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
@@ -188,6 +189,7 @@ export async function initializeAuthenticatedFeatures(
|
|||||||
// Initialize modules
|
// Initialize modules
|
||||||
registerModuleResources();
|
registerModuleResources();
|
||||||
registerModuleProjectTabs();
|
registerModuleProjectTabs();
|
||||||
|
registerModuleModals();
|
||||||
|
|
||||||
authenticatedFeaturesInitialized = true;
|
authenticatedFeaturesInitialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, ModalDefinition>;
|
||||||
|
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<string, ModalDefinition>;
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ModalDefinition } from '@/moduleInitializer/module.types';
|
||||||
|
|
||||||
|
const modals = new Map<string, ModalDefinition>();
|
||||||
|
const listeners = new Set<(modals: Map<string, ModalDefinition>) => void>();
|
||||||
|
|
||||||
|
export function getAll(): Map<string, ModalDefinition> {
|
||||||
|
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<string, ModalDefinition>) => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
|
import type { ModalState } from '@/Interface';
|
||||||
import type { DynamicTabOptions } from '@/utils/modules/tabUtils';
|
import type { DynamicTabOptions } from '@/utils/modules/tabUtils';
|
||||||
import type { RouteRecordRaw } from 'vue-router';
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import type { Component } from 'vue/dist/vue.js';
|
||||||
|
|
||||||
|
export type ModalDefinition = {
|
||||||
|
key: string;
|
||||||
|
component: Component | (() => Promise<Component>);
|
||||||
|
initialState?: ModalState;
|
||||||
|
};
|
||||||
|
|
||||||
export type ResourceMetadata = {
|
export type ResourceMetadata = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -19,4 +27,5 @@ export type FrontendModuleDescription = {
|
|||||||
shared?: DynamicTabOptions[];
|
shared?: DynamicTabOptions[];
|
||||||
};
|
};
|
||||||
resources?: ResourceMetadata[];
|
resources?: ResourceMetadata[];
|
||||||
|
modals?: ModalDefinition[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { InsightsModule } from '../features/insights/module.descriptor';
|
import { InsightsModule } from '../features/insights/module.descriptor';
|
||||||
import type { FrontendModuleDescription } from '@/moduleInitializer/module.types';
|
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.
|
* 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);
|
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
|
* Initialize module routes, done in main.ts
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import { useLocalStorage, useMediaQuery } from '@vueuse/core';
|
|||||||
import type { EventBus } from '@n8n/utils/event-bus';
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
import type { ProjectSharingData } from '@/types/projects.types';
|
||||||
import identity from 'lodash/identity';
|
import identity from 'lodash/identity';
|
||||||
|
import * as modalRegistry from '@/moduleInitializer/modalRegistry';
|
||||||
|
|
||||||
let savedTheme: ThemeOption = 'system';
|
let savedTheme: ThemeOption = 'system';
|
||||||
|
|
||||||
@@ -577,6 +578,54 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
options.banners.forEach(pushBannerToStack);
|
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 {
|
return {
|
||||||
appGridDimensions,
|
appGridDimensions,
|
||||||
appliedTheme,
|
appliedTheme,
|
||||||
@@ -633,6 +682,10 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
initialize,
|
initialize,
|
||||||
moduleTabs,
|
moduleTabs,
|
||||||
registerCustomTabs,
|
registerCustomTabs,
|
||||||
|
registerModal,
|
||||||
|
unregisterModal,
|
||||||
|
initializeModalsFromRegistry,
|
||||||
|
cleanup,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -649,7 +702,7 @@ export const listenForModalChanges = (opts: {
|
|||||||
|
|
||||||
return store.$onAction((result) => {
|
return store.$onAction((result) => {
|
||||||
const { name, after, args } = result;
|
const { name, after, args } = result;
|
||||||
after(async () => {
|
after(() => {
|
||||||
if (!listeningForActions.includes(name)) {
|
if (!listeningForActions.includes(name)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user