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.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 <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 type { EventBus } from '@n8n/utils/event-bus';
|
||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||
import DynamicModalLoader from './DynamicModalLoader.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -340,5 +341,8 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||
<WhatsNewModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<!-- Dynamic modals from modules -->
|
||||
<DynamicModalLoader />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<typeof createTestingPinia>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
||||
let dataStoreStore: ReturnType<typeof mockedStore<typeof useDataStoreStore>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DataStoreResource[]>([]);
|
||||
const totalCount = ref(0);
|
||||
|
||||
const currentPage = ref(1);
|
||||
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 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"
|
||||
/>
|
||||
</template>
|
||||
<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 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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
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 { 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { 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 = {
|
||||
key: string;
|
||||
@@ -19,4 +27,5 @@ export type FrontendModuleDescription = {
|
||||
shared?: DynamicTabOptions[];
|
||||
};
|
||||
resources?: ResourceMetadata[];
|
||||
modals?: ModalDefinition[];
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user