refactor(editor): Derive modal state from the registry instead of mirroring it (no-changelog) (#35859)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.23.2) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Publish API Schema / Cloudflare Pages (push) Waiting to run

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Alex Grozav 2026-08-11 10:54:50 +03:00 committed by GitHub
parent 20e331c867
commit 8b22cca3a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 925 additions and 840 deletions

View File

@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { computed, isReactive } from 'vue';
import type { Component } from 'vue';
import * as modalRegistry from './modalRegistry';
@ -341,4 +342,35 @@ describe('modalRegistry', () => {
expect(keys).toEqual(['test-modal-2', 'test-modal-1', 'test-modal-3']);
});
});
// The registry is the single definition source: consumers derive from it rather
// than mirroring it, which only works if reading it inside a computed tracks.
describe('reactivity', () => {
it('should re-evaluate a computed over getAll on register, unregister and clear', () => {
const keys = computed(() => [...modalRegistry.getAll().keys()]);
expect(keys.value).toEqual([]);
modalRegistry.register(mockModal1);
expect(keys.value).toEqual(['test-modal-1']);
modalRegistry.register(mockModal2);
expect(keys.value).toEqual(['test-modal-1', 'test-modal-2']);
modalRegistry.unregister('test-modal-1');
expect(keys.value).toEqual(['test-modal-2']);
modalRegistry.clear();
expect(keys.value).toEqual([]);
});
it('should not make a registered component reactive', () => {
modalRegistry.register(mockModal1);
// Wrapping a component in a reactive proxy costs render performance and
// breaks identity checks — the registry is shallow for this reason.
expect(isReactive(modalRegistry.get('test-modal-1')?.component)).toBe(false);
expect(modalRegistry.get('test-modal-1')?.component).toBe(mockComponent1);
});
});
});

View File

@ -1,6 +1,13 @@
import { shallowReactive } from 'vue';
import type { ModalDefinition } from '../types/modal';
const modals = new Map<string, ModalDefinition>();
/**
* Shallow-reactive so consumers can derive from the registry with a plain
* `computed` instead of mirroring it through a subscription. Shallow on purpose:
* a definition's `component` must not be turned into a reactive object.
*/
const modals = shallowReactive(new Map<string, ModalDefinition>());
const listeners = new Set<(modals: Map<string, ModalDefinition>) => void>();
export function getAll(): Map<string, ModalDefinition> {

View File

@ -9,7 +9,7 @@ const renderComponent = createComponentRenderer(ChatEmbedModal, {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CHAT_EMBED_MODAL_KEY]: { open: true },
},
},

View File

@ -3,18 +3,22 @@ import { defineComponent, nextTick } from 'vue';
import { screen } from '@testing-library/vue';
import DynamicModalLoader from '@/app/components/DynamicModalLoader.vue';
import { modalRegistry } from '@n8n/frontend-module-sdk';
import type { ModalDefinition } from '@n8n/frontend-module-sdk';
import { createComponentRenderer } from '@/__tests__/render';
// Mock the modalRegistry namespace exported by the SDK barrel
vi.mock('@n8n/frontend-module-sdk', () => ({
modalRegistry: {
getAll: vi.fn(),
subscribe: vi.fn(),
},
}));
/**
* Exercised against the real registry, not a mock of it: the loader derives from
* the registry rather than subscribing to it, so what needs proving is that the
* rendered list follows registration in both directions.
*/
const mockModalRegistry = vi.mocked(modalRegistry);
const modalProps = {
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 },
} as const;
const renderComponent = createComponentRenderer(DynamicModalLoader, {
global: {
@ -38,17 +42,15 @@ const renderComponent = createComponentRenderer(DynamicModalLoader, {
},
});
const renderedModalNames = (container: Element) =>
Array.from(container.querySelectorAll('[data-testid="modal-root"]'), (root) =>
root.getAttribute('data-modal-name'),
);
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 },
},
props: modalProps,
template: '<div data-testid="mock-modal" :data-modal-name="modalName">Mock Modal</div>',
});
@ -56,254 +58,95 @@ describe('DynamicModalLoader', () => {
beforeEach(() => {
vi.clearAllMocks();
modalRegistry.clear();
});
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(),
});
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(),
});
modalRegistry.register({ key: 'testModal1', component: mockModalComponent });
modalRegistry.register({ key: 'testModal2', component: mockModalComponent });
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');
expect(renderedModalNames(container)).toEqual(['testModal1', '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(),
});
modalRegistry.register({ key: 'asyncModal', component: mockAsyncModalComponent });
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(renderedModalNames(container)).toEqual(['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(),
});
it('should render a modal registered after mount', async () => {
const { container } = renderComponent({ pinia: createTestingPinia() });
await nextTick();
expect(renderedModalNames(container)).toEqual([]);
modalRegistry.register({ key: 'newModal', component: mockModalComponent });
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');
expect(renderedModalNames(container)).toEqual(['newModal']);
});
it('should unsubscribe from registry changes on unmount', () => {
mockModalRegistry.getAll.mockReturnValue(new Map());
const mockUnsubscribe = vi.fn();
mockModalRegistry.subscribe.mockReturnValue(mockUnsubscribe);
it('should stop rendering a modal that is unregistered', async () => {
modalRegistry.register({ key: 'staying', component: mockModalComponent });
modalRegistry.register({ key: 'leaving', component: mockModalComponent });
const wrapper = renderComponent({
pinia: createTestingPinia(),
});
const { container } = renderComponent({ pinia: createTestingPinia() });
await nextTick();
expect(renderedModalNames(container)).toEqual(['staying', 'leaving']);
expect(mockUnsubscribe).not.toHaveBeenCalled();
modalRegistry.unregister('leaving');
await nextTick();
wrapper.unmount();
expect(renderedModalNames(container)).toEqual(['staying']);
});
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
it('should render nothing once the registry is cleared', async () => {
modalRegistry.register({ key: 'testModal', component: mockModalComponent });
const { container } = renderComponent({ pinia: createTestingPinia() });
await nextTick();
expect(renderedModalNames(container)).toEqual(['testModal']);
modalRegistry.clear();
await nextTick();
expect(renderedModalNames(container)).toEqual([]);
});
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 },
},
props: modalProps,
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 },
},
props: modalProps,
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(),
});
modalRegistry.register({ key: 'regular', component: regularComponent });
modalRegistry.register({ key: 'async', component: asyncComponent });
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);
expect(renderedModalNames(container)).toEqual(['regular', 'async']);
});
});

View File

@ -1,18 +1,9 @@
<script setup lang="ts">
import { onMounted, onUnmounted, defineAsyncComponent, shallowRef } from 'vue';
import { computed, defineAsyncComponent } from 'vue';
import type { Component } from 'vue';
import { modalRegistry } from '@n8n/frontend-module-sdk';
import ModalRoot from '@/app/components/ModalRoot.vue';
// Keep track of registered modals
// Using shallowRef() to fix "Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead..."
const registeredModals = shallowRef<
Array<{
key: string;
component: Component;
}>
>([]);
// Type guard to check if component is an async component factory
const isAsyncComponentFactory = (
component: Component | (() => Promise<Component>),
@ -20,37 +11,19 @@ const isAsyncComponentFactory = (
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)
// Derived straight from the registry, which is shallow-reactive — registering or
// unregistering a modal re-renders this list with no subscription to keep in sync.
// Neither the registry (shallow) nor a computed's value wraps what it holds, so
// components stay out of the reactive graph ("Vue received a Component that was
// made a reactive object...").
const registeredModals = computed(() =>
Array.from(modalRegistry.getAll(), ([key, modalDef]) => ({
key,
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?.();
});
: modalDef.component,
})),
);
</script>
<template>

View File

@ -129,7 +129,7 @@ describe('FromAiParametersModal', () => {
stubActions: false,
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[FROM_AI_PARAMETERS_MODAL_KEY]: {
open: true,
data: {

View File

@ -4,6 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import { useUIStore } from '@/app/stores/ui.store';
import { nodeViewEventBus } from '@/app/event-bus';
import { IMPORT_WORKFLOW_URL_MODAL_KEY } from '@/app/constants';
import { STORES } from '@n8n/stores';
import userEvent from '@testing-library/user-event';
const ModalStub = {
@ -18,12 +19,13 @@ const ModalStub = {
};
const initialState = {
modalsById: {
[IMPORT_WORKFLOW_URL_MODAL_KEY]: {
open: true,
[STORES.UI]: {
modalStateById: {
[IMPORT_WORKFLOW_URL_MODAL_KEY]: {
open: true,
},
},
},
modalStack: [IMPORT_WORKFLOW_URL_MODAL_KEY],
};
const global = {

View File

@ -46,7 +46,7 @@ const initialState = {
},
},
[STORES.UI]: {
modalsById: {
modalStateById: {
[WORKFLOW_PUBLISH_MODAL_KEY]: {
open: true,
},

View File

@ -38,9 +38,8 @@ const renderedSlotProps = () => JSON.parse(screen.getByTestId('modal-content').t
describe('ModalRoot', () => {
beforeEach(() => {
setActivePinia(createPinia());
modalRegistry.clear();
// Nothing has registered yet — the state every reader sees on first paint.
useUIStore().modalsById = {};
modalRegistry.clear();
});
it('renders nothing for a key that is not registered', () => {
@ -71,7 +70,7 @@ describe('ModalRoot', () => {
it('passes modal state to the slot once the key is open', () => {
const uiStore = useUIStore();
uiStore.modalsById = {
uiStore.modalStateById = {
[MODAL_KEY]: { open: true, mode: 'edit', activeId: '42', data: { foo: 'bar' } },
};
uiStore.openModal(MODAL_KEY);
@ -94,10 +93,24 @@ describe('ModalRoot', () => {
renderModalRoot({ props: { name: MODAL_KEY } });
expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument();
uiStore.registerModal(MODAL_KEY);
modalRegistry.register({ key: MODAL_KEY, component: {} });
uiStore.openModal(MODAL_KEY);
await nextTick();
expect(screen.getByTestId('modal-content')).toBeInTheDocument();
});
it('picks up the initial state a modal registers with, without the store mirroring it', async () => {
renderModalRoot({ props: { name: MODAL_KEY, keepAlive: true } });
expect(renderedSlotProps()).toMatchObject({ open: false });
modalRegistry.register({
key: MODAL_KEY,
component: {},
initialState: { open: false, mode: 'edit' },
});
await nextTick();
expect(renderedSlotProps()).toMatchObject({ mode: 'edit' });
});
});

View File

@ -22,7 +22,7 @@ const uiStore = useUIStore();
// Resolves to a closed state while `name` is not registered, so this renders
// nothing instead of throwing when it mounts ahead of registration.
const modalState = computed(() => uiStore.modalStateById[props.name]);
const modalState = computed(() => uiStore.modalsById[props.name]);
</script>
<template>

View File

@ -55,7 +55,7 @@ describe('VersionUpdateCTA', () => {
beforeEach(() => {
createTestingPinia();
uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[WHATS_NEW_MODAL_KEY]: {
open: true,
},

View File

@ -62,7 +62,7 @@ describe('WhatsNewModal', () => {
beforeEach(() => {
createTestingPinia();
uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[WHATS_NEW_MODAL_KEY]: {
open: true,
},

View File

@ -89,8 +89,6 @@ export const registerModuleModals = () => {
modalRegistry.register(modalDef);
});
});
// Subscribe to modal registry changes
useUIStore().initializeModalsFromRegistry();
};
/**

View File

@ -0,0 +1,239 @@
import {
ABOUT_MODAL_KEY,
ADD_EXECUTION_TO_DATASET_MODAL_KEY,
AGENT_CONFIRMATION_MODAL_KEY,
AI_BUILDER_DIFF_MODAL_KEY,
AI_GATEWAY_TOP_UP_MODAL_KEY,
BINARY_DATA_VIEW_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
CONFIRM_PASSWORD_MODAL_KEY,
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
DUPLICATE_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
MIGRATE_WORKFLOW_MODAL_KEY,
NEW_ASSISTANT_SESSION_MODAL,
NPS_SURVEY_MODAL_KEY,
PROMPT_MFA_CODE_MODAL_KEY,
SECRETS_PROVIDER_CONNECTION_MODAL_KEY,
DELETE_SECRETS_PROVIDER_MODAL_KEY,
SETUP_CREDENTIALS_MODAL_KEY,
STOP_MANY_EXECUTIONS_MODAL_KEY,
TRIAL_INTRO_MODAL_KEY,
VERSIONS_MODAL_KEY,
WHATS_NEW_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
WORKFLOW_HISTORY_DIFF_MODAL_KEY,
WORKFLOW_HISTORY_NAME_VERSION_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
} from '@/app/constants';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/features/collaboration/projects/projects.constants';
import {
DELETE_FOLDER_MODAL_KEY,
MOVE_FOLDER_MODAL_KEY,
} from '@/features/core/folders/folders.constants';
import {
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
} from '@/features/credentials/credentials.constants';
import { DEBUG_PAYWALL_MODAL_KEY } from '@/features/execution/executions/executions.constants';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY } from '@/features/settings/apiKeys/apiKeys.constants';
import {
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
} from '@/features/settings/communityNodes/communityNodes.constants';
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/features/settings/usage/usage.constants';
import {
DELETE_USER_MODAL_KEY,
INVITE_USER_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
} from '@/features/settings/users/users.constants';
import {
SOURCE_CONTROL_PULL_MODAL_KEY,
SOURCE_CONTROL_PULL_RESULT_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
} from '@/features/integrations/sourceControl.ee/sourceControl.constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
} from '@/features/shared/tags/tags.constants';
import type { ModalState } from '@/Interface';
/**
* Modal definitions still owned by the shell: the key and the state it starts in.
*
* Every entry here is rendered by a hand-written `<ModalRoot>` in `Modals.vue`
* — `ui.store.registration.spec.ts` holds those two in sync. Module-owned modals
* are not here; they register their definition through `modalRegistry` and render
* through `DynamicModalLoader`.
*
* This catalogue shrinks by one entry per modal moved onto the registry, and is
* empty when the inversion is complete. Nothing else should be added to it.
*/
export const SHELL_MODAL_INITIAL_STATE: Readonly<Record<string, ModalState>> = Object.freeze({
[ABOUT_MODAL_KEY]: { open: false },
[ADD_EXECUTION_TO_DATASET_MODAL_KEY]: { open: false, data: {} },
[AGENT_CONFIRMATION_MODAL_KEY]: { open: false },
[AI_BUILDER_DIFF_MODAL_KEY]: { open: false },
[AI_GATEWAY_TOP_UP_MODAL_KEY]: { open: false },
[ANNOTATION_TAGS_MANAGER_MODAL_KEY]: { open: false },
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: {
open: false,
data: {
activeId: null,
mode: '',
},
},
[BINARY_DATA_VIEW_MODAL_KEY]: { open: false },
[CHANGE_PASSWORD_MODAL_KEY]: { open: false },
[CHAT_EMBED_MODAL_KEY]: { open: false },
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
},
[COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: { open: false },
[COMMUNITY_PLUS_ENROLLMENT_MODAL]: {
open: false,
data: {
customHeading: undefined,
},
},
[CONFIRM_PASSWORD_MODAL_KEY]: { open: false },
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
showAuthSelector: false,
closeOnSave: false,
} as ModalState,
[CREDENTIAL_RESOLVER_EDIT_MODAL_KEY]: { open: false },
[CREDENTIAL_SELECT_MODAL_KEY]: { open: false },
[DEBUG_PAYWALL_MODAL_KEY]: { open: false },
[DELETE_FOLDER_MODAL_KEY]: {
open: false,
activeId: null,
data: {
workflowListEventBus: undefined,
content: {
workflowCount: 0,
subFolderCount: 0,
},
},
},
[DELETE_SECRETS_PROVIDER_MODAL_KEY]: { open: false },
[DELETE_USER_MODAL_KEY]: {
open: false,
activeId: null,
},
[DUPLICATE_MODAL_KEY]: { open: false },
[EXPERIMENT_TEMPLATE_RECO_V2_KEY]: {
open: false,
data: {
nodeName: '',
},
},
[EXPERIMENT_TEMPLATE_RECO_V3_KEY]: { open: false },
[EXTERNAL_SECRETS_PROVIDER_MODAL_KEY]: { open: false },
[FROM_AI_PARAMETERS_MODAL_KEY]: {
open: false,
data: {
nodeName: undefined,
},
},
[IMPORT_CURL_MODAL_KEY]: {
open: false,
data: {
curlCommands: {},
},
},
[IMPORT_WORKFLOW_URL_MODAL_KEY]: {
open: false,
data: {
url: '',
},
},
[INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY]: { open: false },
[INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY]: { open: false },
[INVITE_USER_MODAL_KEY]: { open: false },
[LOG_STREAM_MODAL_KEY]: {
open: false,
data: undefined,
},
[MFA_SETUP_MODAL_KEY]: { open: false },
[MIGRATE_WORKFLOW_MODAL_KEY]: { open: false },
[MOVE_FOLDER_MODAL_KEY]: {
open: false,
activeId: null,
data: {
workflowListEventBus: undefined,
},
},
[NEW_ASSISTANT_SESSION_MODAL]: { open: false },
[NPS_SURVEY_MODAL_KEY]: { open: false },
[PERSONALIZATION_MODAL_KEY]: { open: false },
[PROJECT_MOVE_RESOURCE_MODAL]: { open: false },
[PROMPT_MFA_CODE_MODAL_KEY]: { open: false },
[SECRETS_PROVIDER_CONNECTION_MODAL_KEY]: { open: false },
[SETUP_CREDENTIALS_MODAL_KEY]: { open: false },
[SOURCE_CONTROL_PULL_MODAL_KEY]: { open: false },
[SOURCE_CONTROL_PULL_RESULT_MODAL_KEY]: { open: false },
[SOURCE_CONTROL_PUSH_MODAL_KEY]: { open: false },
[STOP_MANY_EXECUTIONS_MODAL_KEY]: { open: false, data: {} },
[TAGS_MANAGER_MODAL_KEY]: { open: false },
[TRIAL_INTRO_MODAL_KEY]: { open: false },
[VARIABLE_MODAL_KEY]: { open: false },
[VERSIONS_MODAL_KEY]: { open: false },
[WHATS_NEW_MODAL_KEY]: {
open: false,
data: {
articleId: undefined,
},
},
[WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY]: {
open: false,
data: {
triggerType: '',
workflowName: '',
workflowId: '',
webhookPath: '',
node: '',
},
},
[WORKFLOW_ACTIVE_MODAL_KEY]: { open: false },
[WORKFLOW_DESCRIPTION_MODAL_KEY]: { open: false },
[WORKFLOW_DIFF_MODAL_KEY]: { open: false },
[WORKFLOW_EXTRACTION_NAME_MODAL_KEY]: {
open: false,
data: {
workflowName: '',
},
},
[WORKFLOW_HISTORY_DIFF_MODAL_KEY]: { open: false },
[WORKFLOW_HISTORY_NAME_VERSION_MODAL_KEY]: { open: false },
[WORKFLOW_HISTORY_PUBLISH_MODAL_KEY]: { open: false },
[WORKFLOW_HISTORY_VERSION_RESTORE]: { open: false },
[WORKFLOW_HISTORY_VERSION_UNPUBLISH]: { open: false },
[WORKFLOW_PUBLISH_MODAL_KEY]: { open: false },
[WORKFLOW_SETTINGS_MODAL_KEY]: { open: false },
[WORKFLOW_SHARE_MODAL_KEY]: { open: false },
});

View File

@ -1,167 +1,87 @@
import { describe, it, expect } from 'vitest';
import ts from 'typescript';
import modalsVueContent from '../components/Modals.vue?raw';
// eslint-disable-next-line import-x/extensions
import uiStoreContent from './ui.store.ts?raw';
const MODAL_KEY_REGEX = /^[A-Z_][A-Z_0-9]+$/;
const MODAL_ROOT_NAME_ATTR_REGEX = /<ModalRoot\s+:name\s*=\s*(['"])([A-Z_][A-Z_0-9]+)\1/g;
function uniqueSorted(values: string[]): string[] {
return [...new Set(values)].sort();
}
import shellModalsContent from './defaults/modals.ts?raw';
/**
* Extract all modal keys used in <ModalRoot :name="..."> from Modals.vue.
* This intentionally only supports constant-like modal keys (ALL_CAPS_WITH_UNDERSCORES).
* A modal key with nothing defining it resolves to a closed state rather than
* throwing (`ui.store.ts`, `withFallback`) — deliberate, since Seam A, and the
* reason a missing definition now fails *silently*: the modal simply never opens.
*
* So this holds the two halves together: every `<ModalRoot :name="SOME_KEY">` in
* the tree must have a definition backing it, either the shell catalogue in
* `defaults/modals.ts` or — for module-owned modals, which register a component
* alongside their key and render through `DynamicModalLoader` — `modalRegistry`.
* A hand-written root is by definition the shell's, so the catalogue is where its
* key has to be.
*
* Both sides are read as source text and compared by *constant name*: the same
* `WORKFLOW_SETTINGS_MODAL_KEY` identifier has to appear on both. That keeps the
* check on the pairing it exists to protect, independent of how either side is
* written.
*
* As modals move onto the registry both sides shrink together — the `<ModalRoot>`
* block goes, and so does the catalogue entry. When the catalogue is empty this
* file has nothing left to check and goes with it.
*
* Not asserted in reverse: a definition may be rendered by something other than a
* `<ModalRoot>` (`WorkflowHistory.vue` drives its diff modal through `<Modal>`
* directly), so an entry with no matching root is not necessarily dead.
*/
function extractModalKeysFromModalsVue(content: string): string[] {
const keys: string[] = [];
let match: RegExpExecArray | null;
while ((match = MODAL_ROOT_NAME_ATTR_REGEX.exec(content)) !== null) {
keys.push(match[2]);
const MODAL_ROOT_NAME_ATTR_REGEX = /<ModalRoot\s[^>]*?:name\s*=\s*(['"])([A-Z_][A-Z_0-9]*)\1/g;
const CATALOGUE_ENTRY_REGEX = /^\t\[([A-Z_][A-Z_0-9]*)\]\s*:/gm;
const vueSources = import.meta.glob<string>('../../**/*.vue', {
query: '?raw',
import: 'default',
eager: true,
});
/** Constant-named modal keys rendered by a hand-written `<ModalRoot>`, by file. */
function renderedModalKeys(): Map<string, string> {
const keys = new Map<string, string>();
for (const [path, content] of Object.entries(vueSources)) {
for (const [, , key] of content.matchAll(MODAL_ROOT_NAME_ATTR_REGEX)) {
keys.set(key, path);
}
}
if (keys.length === 0) {
throw new Error(
'No modal keys were extracted from Modals.vue. ' +
'The <ModalRoot :name="..."> pattern may have changed, or modal keys are no longer constant-like.',
);
}
return uniqueSorted(keys);
return keys;
}
/**
* Extract modal keys registered in modalsById from ui.store.ts (AST-based).
*
* It looks inside the object literal passed to ref(...) for:
* 1) Computed keys: { [DELETE_USER_MODAL_KEY]: { ... } }
* 2) Identifiers / strings inside array literals found in spread assignments
* (e.g. ...something([ABOUT_MODAL_KEY, ...])).
*
* This avoids relying on specific helpers like Object.fromEntries().
*/
function extractRegisteredModalKeys(content: string): string[] {
const sourceFile = ts.createSourceFile('ui.store.ts', content, ts.ScriptTarget.Latest, true);
const registered = new Set<string>();
describe('shell modal definitions', () => {
const rendered = renderedModalKeys();
const defined = new Set(
[...shellModalsContent.matchAll(CATALOGUE_ENTRY_REGEX)].map(([, key]) => key),
);
const addIfModalLike = (value: string) => {
if (MODAL_KEY_REGEX.test(value)) registered.add(value);
};
const addFromArrayElement = (el: ts.Expression) => {
if (ts.isIdentifier(el)) addIfModalLike(el.text);
if (ts.isStringLiteral(el)) addIfModalLike(el.text);
if (ts.isAsExpression(el) && ts.isIdentifier(el.expression)) {
addIfModalLike(el.expression.text);
}
};
const collectFromArrayLiteralsInSubtree = (node: ts.Node) => {
const walk = (n: ts.Node) => {
if (ts.isArrayLiteralExpression(n)) {
for (const el of n.elements) addFromArrayElement(el);
}
ts.forEachChild(n, walk);
};
walk(node);
};
const findModalsByIdInitializer = (): ts.Expression | undefined => {
let found: ts.Expression | undefined;
const visit = (node: ts.Node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'modalsById' &&
node.initializer
) {
found = node.initializer;
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return found;
};
const findObjectLiteralPassedToRef = (
expr: ts.Expression,
): ts.ObjectLiteralExpression | undefined => {
if (!ts.isCallExpression(expr)) return undefined;
const arg0 = expr.arguments[0];
return arg0 && ts.isObjectLiteralExpression(arg0) ? arg0 : undefined;
};
const init = findModalsByIdInitializer();
if (!init) throw new Error('Could not find modalsById declaration in ui.store.ts');
const obj = findObjectLiteralPassedToRef(init);
if (!obj) {
throw new Error(
'Could not find an object literal passed to ref(...) for modalsById in ui.store.ts',
it('reads both sides — the shapes it matches on still exist', () => {
// Guards the glob and the two regexes: if the glob path breaks or either side
// is rewritten so nothing matches, this fails loudly instead of the emptiness
// quietly passing the check below.
expect(Object.keys(vueSources).length, 'the .vue glob matched almost nothing').toBeGreaterThan(
100,
);
}
for (const prop of obj.properties) {
// { [SOME_MODAL_KEY]: {...} }
if (ts.isPropertyAssignment(prop) && ts.isComputedPropertyName(prop.name)) {
const keyExpr = prop.name.expression;
if (ts.isIdentifier(keyExpr)) addIfModalLike(keyExpr.text);
if (ts.isStringLiteral(keyExpr)) addIfModalLike(keyExpr.text);
}
// { ...somethingThatContains([A, B, C]) }
if (ts.isSpreadAssignment(prop)) {
collectFromArrayLiteralsInSubtree(prop.expression);
}
}
if (registered.size === 0) {
throw new Error(
'Extracted 0 modal keys from modalsById initializer (AST). The store structure likely changed.',
expect(rendered.size, 'no <ModalRoot :name="SOME_KEY"> found in any .vue file').toBeGreaterThan(
0,
);
}
expect(defined.size, 'no [SOME_KEY]: entries found in defaults/modals.ts').toBeGreaterThan(0);
});
return [...registered].sort();
}
describe('UI Store - Modal Registration', () => {
it('should have all modal keys used in Modals.vue registered in ui.store.ts', () => {
const usedModalKeys = extractModalKeysFromModalsVue(modalsVueContent);
const registeredModalKeys = extractRegisteredModalKeys(uiStoreContent);
expect(usedModalKeys.length).toBeGreaterThan(0);
expect(registeredModalKeys.length).toBeGreaterThan(0);
const missingKeys = usedModalKeys.filter((key) => !registeredModalKeys.includes(key));
it('defines every modal key a <ModalRoot> renders', () => {
const missing = [...rendered]
.filter(([key]) => !defined.has(key))
.map(([key, path]) => ` • ${key} (${path})`);
expect(
missingKeys,
missingKeys.length
? `\n\n❌ Modal registration check failed.\n\nMissing in ui.store.ts (modalsById):\n${missingKeys.map((k) => ` • ${k}`).join('\n')}\n`
missing,
missing.length
? `\n\n❌ Rendered by a <ModalRoot> with no entry in SHELL_MODAL_INITIAL_STATE:\n${missing.join(
'\n',
)}\n\nAdd the entry, or move the modal onto modalRegistry and delete its <ModalRoot>.\n`
: undefined,
).toEqual([]);
});
it('should warn about unused modal keys registered in ui.store.ts', () => {
const usedModalKeys = extractModalKeysFromModalsVue(modalsVueContent);
const registeredModalKeys = extractRegisteredModalKeys(uiStoreContent);
const unusedKeys = registeredModalKeys.filter((key) => !usedModalKeys.includes(key));
if (unusedKeys.length) {
// eslint-disable-next-line no-console
console.warn(
`\n⚠️ Unused modal key(s) registered in ui.store.ts but not used in Modals.vue:\n${unusedKeys
.map((k) => ` • ${k}`)
.join('\n')}\n`,
);
}
});
});

View File

@ -1,9 +1,15 @@
import { createPinia, setActivePinia } from 'pinia';
import { useUIStore } from '@/app/stores/ui.store';
import { modalRegistry } from '@n8n/frontend-module-sdk';
import type { ModalState } from '@/Interface';
import { IMPORT_CURL_MODAL_KEY } from '@/app/constants';
import { listenForModalChanges, useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
describe('UI Store', () => {
beforeEach(() => {
setActivePinia(createPinia());
modalRegistry.clear();
});
describe('markStateDirty', () => {
@ -78,32 +84,203 @@ describe('UI Store', () => {
});
});
describe('modalStateById', () => {
describe('modalsById', () => {
const MODAL_KEY = 'someFeatureModal';
it('should resolve an unregistered key to a closed state instead of undefined', () => {
const uiStore = useUIStore();
uiStore.modalsById = {};
expect(uiStore.modalStateById[MODAL_KEY]).toEqual({ open: false });
expect(uiStore.modalsById[MODAL_KEY]).toEqual({ open: false });
});
it('should return the stored state for a registered key', () => {
it('should resolve a registered key to the state it was registered with', () => {
const uiStore = useUIStore();
uiStore.modalsById = {};
uiStore.registerModal(MODAL_KEY, { open: false, mode: 'edit' });
modalRegistry.register({
key: MODAL_KEY,
component: {},
initialState: { open: false, mode: 'edit' },
});
expect(uiStore.modalStateById[MODAL_KEY]).toEqual({ open: false, mode: 'edit' });
expect(uiStore.modalsById[MODAL_KEY]).toEqual({ open: false, mode: 'edit' });
});
it('should reflect a key registered after the first read', () => {
const uiStore = useUIStore();
uiStore.modalsById = {};
expect(uiStore.modalStateById[MODAL_KEY].open).toBe(false);
expect(uiStore.modalsById[MODAL_KEY].open).toBe(false);
uiStore.registerModal(MODAL_KEY, { open: true });
modalRegistry.register({
key: MODAL_KEY,
component: {},
initialState: { open: true },
});
expect(uiStore.modalStateById[MODAL_KEY].open).toBe(true);
expect(uiStore.modalsById[MODAL_KEY].open).toBe(true);
});
it('should stop resolving a key once its definition is unregistered', () => {
const uiStore = useUIStore();
modalRegistry.register({
key: MODAL_KEY,
component: {},
initialState: { open: false, mode: 'edit' },
});
expect(uiStore.modalsById[MODAL_KEY].mode).toBe('edit');
modalRegistry.unregister(MODAL_KEY);
expect(uiStore.modalsById[MODAL_KEY]).toEqual({ open: false });
});
it('should keep runtime state out of the definitions it resolves over', () => {
const uiStore = useUIStore();
modalRegistry.register({
key: MODAL_KEY,
component: {},
initialState: { open: false, mode: 'edit' },
});
uiStore.openModalWithData({ name: MODAL_KEY, data: { foo: 'bar' } });
// The definition's `mode` survives; only what was written is stored.
expect(uiStore.modalsById[MODAL_KEY]).toEqual({
open: true,
mode: 'edit',
data: { foo: 'bar' },
});
expect(uiStore.modalStateById[MODAL_KEY]).toEqual({
open: true,
data: { foo: 'bar' },
});
});
it('should materialize state lazily — an untouched modal has none', () => {
const uiStore = useUIStore();
modalRegistry.register({ key: MODAL_KEY, component: {} });
expect(uiStore.modalStateById).toEqual({});
uiStore.openModal(MODAL_KEY);
expect(Object.keys(uiStore.modalStateById)).toEqual([MODAL_KEY]);
});
it('should open a key that was never registered', () => {
const uiStore = useUIStore();
// dataTable builds per-row keys at runtime, so registration cannot be mandatory.
const perRowKey = `${MODAL_KEY}-row-42`;
uiStore.openModal(perRowKey);
expect(uiStore.modalsById[perRowKey].open).toBe(true);
expect(uiStore.isModalActiveById[perRowKey]).toBe(true);
uiStore.closeModal(perRowKey);
expect(uiStore.modalsById[perRowKey].open).toBe(false);
});
it('should resolve shell-owned keys without anything registering them', () => {
const uiStore = useUIStore();
expect(uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]).toMatchObject({
open: false,
mode: '',
activeId: null,
});
});
// Runtime state is scoped to the lifetime of the definition it accumulated
// under. Registered keys and ad-hoc ones are told apart by the registry
// itself: only a key that was in it can be taken out of it.
describe('when a definition is unregistered', () => {
const register = (initialState?: ModalState) =>
modalRegistry.register({ key: MODAL_KEY, component: {}, initialState });
it('should forget the runtime state that accumulated under it', () => {
const uiStore = useUIStore();
register({ open: false, mode: 'edit' });
uiStore.openModalWithData({ name: MODAL_KEY, data: { secret: 'shhh' } });
modalRegistry.unregister(MODAL_KEY);
expect(uiStore.modalsById[MODAL_KEY]).toEqual({ open: false });
expect(uiStore.modalStateById[MODAL_KEY]).toBeUndefined();
});
it('should not reopen with stale data when the key is registered again', () => {
const uiStore = useUIStore();
register({ open: false, mode: 'edit' });
uiStore.openModalWithData({ name: MODAL_KEY, data: { secret: 'shhh' } });
modalRegistry.unregister(MODAL_KEY);
register({ open: false, mode: 'edit' });
expect(uiStore.modalsById[MODAL_KEY]).toEqual({ open: false, mode: 'edit' });
});
it('should drop it from the modal stack so nothing counts as open', () => {
const uiStore = useUIStore();
register();
uiStore.openModal(MODAL_KEY);
expect(uiStore.isAnyModalOpen).toBe(true);
modalRegistry.unregister(MODAL_KEY);
expect(uiStore.isAnyModalOpen).toBe(false);
expect(uiStore.isModalActiveById[MODAL_KEY]).toBe(false);
});
it('should leave an ad-hoc key alone — it never had a definition to lose', () => {
const uiStore = useUIStore();
// dataTable's per-row keys are opened without ever being registered, so
// nothing about them may hinge on the registry.
const perRowKey = `${MODAL_KEY}-row-42`;
register();
uiStore.openModalWithData({ name: perRowKey, data: { rowId: '42' } });
modalRegistry.unregister(MODAL_KEY);
modalRegistry.clear();
expect(uiStore.modalsById[perRowKey]).toEqual({ open: true, data: { rowId: '42' } });
expect(uiStore.isModalActiveById[perRowKey]).toBe(true);
});
it('should leave shell-owned keys alone when the registry empties', () => {
const uiStore = useUIStore();
uiStore.openModal(CREDENTIAL_EDIT_MODAL_KEY);
modalRegistry.clear();
expect(uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open).toBe(true);
});
});
// The shell catalogue is a module-level constant shared by every store
// instance. What the store resolves must never be a reference into it, or one
// session's writes reach the next.
describe('isolation from the shell catalogue', () => {
it('should not hand out state that writes through to the next store instance', () => {
const first = useUIStore();
const handedOut = first.modalsById[IMPORT_CURL_MODAL_KEY].data as {
curlCommands: Record<string, string>;
};
handedOut.curlCommands['node-1'] = 'curl https://leaked.example';
setActivePinia(createPinia());
expect(useUIStore().modalsById[IMPORT_CURL_MODAL_KEY].data).toEqual({ curlCommands: {} });
});
it('should give two instances independent state for the same key', () => {
const first = useUIStore();
first.openModalWithData({
name: IMPORT_CURL_MODAL_KEY,
data: { curlCommands: { a: 'b' } },
});
setActivePinia(createPinia());
expect(useUIStore().modalsById[IMPORT_CURL_MODAL_KEY].open).toBe(false);
});
});
});
@ -112,16 +289,12 @@ describe('UI Store', () => {
it('should report an unregistered key as inactive instead of undefined', () => {
const uiStore = useUIStore();
uiStore.modalsById = {};
expect(uiStore.isModalActiveById[MODAL_KEY]).toBe(false);
});
it('should still report only the topmost open modal as active', () => {
const uiStore = useUIStore();
uiStore.modalsById = {};
uiStore.registerModal('first');
uiStore.registerModal('second');
uiStore.openModal('first');
uiStore.openModal('second');
@ -130,4 +303,66 @@ describe('UI Store', () => {
expect(uiStore.isModalActiveById.first).toBe(false);
});
});
describe('listenForModalChanges', () => {
const MODAL_KEY = 'someFeatureModal';
const listen = () => {
const onModalOpened = vi.fn();
const onModalClosed = vi.fn();
const stop = listenForModalChanges({
store: useUIStore(),
onModalOpened,
onModalClosed,
});
return { onModalOpened, onModalClosed, stop };
};
it('should fire for open and close', () => {
const uiStore = useUIStore();
const { onModalOpened, onModalClosed } = listen();
uiStore.openModal(MODAL_KEY);
expect(onModalOpened).toHaveBeenCalledWith(MODAL_KEY);
uiStore.closeModal(MODAL_KEY);
expect(onModalClosed).toHaveBeenCalledWith(MODAL_KEY);
});
// The reason this is derived from state rather than filtered on action names:
// every path that closes a modal has to be observable, and the sweep is one.
it('should fire when a modal closes because its definition was unregistered', () => {
const uiStore = useUIStore();
modalRegistry.register({ key: MODAL_KEY, component: {} });
const { onModalClosed } = listen();
uiStore.openModal(MODAL_KEY);
modalRegistry.unregister(MODAL_KEY);
expect(onModalClosed).toHaveBeenCalledWith(MODAL_KEY);
});
it('should report each modal in a stack separately', () => {
const uiStore = useUIStore();
const { onModalOpened, onModalClosed } = listen();
uiStore.openModal('first');
uiStore.openModal('second');
expect(onModalOpened.mock.calls).toEqual([['first'], ['second']]);
uiStore.closeModal('first');
uiStore.closeModal('second');
expect(onModalClosed.mock.calls).toEqual([['first'], ['second']]);
});
it('should stop listening when the returned handle is called', () => {
const uiStore = useUIStore();
const { onModalOpened, stop } = listen();
stop();
uiStore.openModal(MODAL_KEY);
expect(onModalOpened).not.toHaveBeenCalled();
});
});
});

View File

@ -1,86 +1,15 @@
import {
ABOUT_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
DUPLICATE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
VIEWS,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
SECRETS_PROVIDER_CONNECTION_MODAL_KEY,
DELETE_SECRETS_PROVIDER_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
NEW_ASSISTANT_SESSION_MODAL,
PROMPT_MFA_CODE_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
LOCAL_STORAGE_THEME,
LOCAL_STORAGE_SIDEBAR_WIDTH,
WHATS_NEW_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
CONFIRM_PASSWORD_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
BINARY_DATA_VIEW_MODAL_KEY,
STOP_MANY_EXECUTIONS_MODAL_KEY,
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_DIFF_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
WORKFLOW_HISTORY_NAME_VERSION_MODAL_KEY,
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
AI_BUILDER_DIFF_MODAL_KEY,
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY,
AI_GATEWAY_TOP_UP_MODAL_KEY,
AGENT_CONFIRMATION_MODAL_KEY,
ADD_EXECUTION_TO_DATASET_MODAL_KEY,
TRIAL_INTRO_MODAL_KEY,
MIGRATE_WORKFLOW_MODAL_KEY,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
} from '@/features/shared/tags/tags.constants';
import { DEBUG_PAYWALL_MODAL_KEY } from '@/features/execution/executions/executions.constants';
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/features/settings/usage/usage.constants';
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
import {
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
} from '@/features/credentials/credentials.constants';
import {
DELETE_USER_MODAL_KEY,
INVITE_USER_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
} from '@/features/settings/users/users.constants';
import { VIEWS, LOCAL_STORAGE_THEME, LOCAL_STORAGE_SIDEBAR_WIDTH } from '@/app/constants';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { DELETE_USER_MODAL_KEY } from '@/features/settings/users/users.constants';
import {
DELETE_FOLDER_MODAL_KEY,
MOVE_FOLDER_MODAL_KEY,
} from '@/features/core/folders/folders.constants';
import type { WorkflowListEventMap } from '@/features/core/folders/folders.types';
import {
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
SOURCE_CONTROL_PULL_RESULT_MODAL_KEY,
} from '@/features/integrations/sourceControl.ee/sourceControl.constants';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/features/collaboration/projects/projects.constants';
import {
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
} from '@/features/settings/communityNodes/communityNodes.constants';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY } from '@/features/settings/apiKeys/apiKeys.constants';
import { STORES } from '@n8n/stores';
import type {
XYPosition,
@ -97,7 +26,8 @@ import type {
import { defineStore } from 'pinia';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { applyThemeToBody, getThemeOverride, isValidTheme } from './ui.utils';
import { computed, ref } from 'vue';
import { SHELL_MODAL_INITIAL_STATE } from './defaults/modals';
import { computed, ref, watch } from 'vue';
import type { IMenuItem } from '@n8n/design-system';
import type { Connection } from '@vue-flow/core';
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
@ -135,6 +65,38 @@ function withFallback<T>(source: Record<string, T>, fallback: T): Record<string,
});
}
/**
* Definitions (the key and the state it starts in) for every modal the app knows
* about: the shell's own catalogue plus whatever modules have registered.
*
* The registry is shallow-reactive, so reading it here is enough to make callers
* re-derive when a module registers or unregisters a modal — the store keeps no
* mirror of it.
*
* `shellDefaults` is the caller's own copy of the catalogue (see `ownedCopyOf`) —
* a definition resolved from here is handed straight to components, and several
* of them still write to modal state in place.
*/
function modalDefinitionsById(shellDefaults: Record<string, ModalState>) {
const definitions: Record<string, ModalState> = { ...shellDefaults };
for (const [key, definition] of modalRegistry.getAll()) {
definitions[key] = definition.initialState ?? CLOSED_MODAL_STATE;
}
return definitions;
}
/**
* Deep copy of the initial-state catalogue, so nothing the store resolves is a
* reference into a module-level constant that outlives every store instance.
* `structuredClone` preserves shared references within the input, so the entries
* that share a closed-state object still share one copy.
*/
function ownedCopyOf(catalogue: Readonly<Record<string, ModalState>>): Record<string, ModalState> {
return structuredClone(catalogue) as Record<string, ModalState>;
}
export const useUIStore = defineStore(STORES.UI, () => {
const telemetry = useTelemetry();
const activeActions = ref<string[]>([]);
@ -146,165 +108,18 @@ export const useUIStore = defineStore(STORES.UI, () => {
write: identity,
},
});
const modalsById = ref<Record<string, ModalState>>({
...Object.fromEntries(
[
ABOUT_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
CONFIRM_PASSWORD_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DUPLICATE_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
INVITE_USER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
PROMPT_MFA_CODE_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
SOURCE_CONTROL_PULL_RESULT_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
SECRETS_PROVIDER_CONNECTION_MODAL_KEY,
DELETE_SECRETS_PROVIDER_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
VARIABLE_MODAL_KEY,
BINARY_DATA_VIEW_MODAL_KEY,
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_DIFF_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
WORKFLOW_HISTORY_NAME_VERSION_MODAL_KEY,
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
AI_BUILDER_DIFF_MODAL_KEY,
INSTANCE_AI_CREDENTIAL_SETUP_MODAL_KEY,
INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY,
AI_GATEWAY_TOP_UP_MODAL_KEY,
AGENT_CONFIRMATION_MODAL_KEY,
TRIAL_INTRO_MODAL_KEY,
MIGRATE_WORKFLOW_MODAL_KEY,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {
open: false,
activeId: null,
},
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
},
[IMPORT_CURL_MODAL_KEY]: {
open: false,
data: {
curlCommands: {},
},
},
[LOG_STREAM_MODAL_KEY]: {
open: false,
data: undefined,
},
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: {
open: false,
data: {
activeId: null,
mode: '',
},
},
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
showAuthSelector: false,
closeOnSave: false,
} as ModalState,
[DELETE_FOLDER_MODAL_KEY]: {
open: false,
activeId: null,
data: {
workflowListEventBus: undefined,
content: {
workflowCount: 0,
subFolderCount: 0,
},
},
},
[MOVE_FOLDER_MODAL_KEY]: {
open: false,
activeId: null,
data: {
workflowListEventBus: undefined,
},
},
[COMMUNITY_PLUS_ENROLLMENT_MODAL]: {
open: false,
data: {
customHeading: undefined,
},
},
[WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY]: {
open: false,
data: {
triggerType: '',
workflowName: '',
workflowId: '',
webhookPath: '',
node: '',
},
},
[FROM_AI_PARAMETERS_MODAL_KEY]: {
open: false,
data: {
nodeName: undefined,
},
},
[STOP_MANY_EXECUTIONS_MODAL_KEY]: {
open: false,
data: {},
},
[ADD_EXECUTION_TO_DATASET_MODAL_KEY]: {
open: false,
data: {},
},
[IMPORT_WORKFLOW_URL_MODAL_KEY]: {
open: false,
data: {
url: '',
},
},
[WORKFLOW_EXTRACTION_NAME_MODAL_KEY]: {
open: false,
data: {
workflowName: '',
},
},
[WHATS_NEW_MODAL_KEY]: {
open: false,
data: {
articleId: undefined,
},
},
[EXPERIMENT_TEMPLATE_RECO_V2_KEY]: {
open: false,
data: {
nodeName: '',
},
},
});
/** This store instance's copy of the shell catalogue (`ownedCopyOf`). */
const shellModalDefaults = ownedCopyOf(SHELL_MODAL_INITIAL_STATE);
/**
* Runtime modal state, keyed by modal key — only the keys actually touched at
* runtime (opened, closed, given data) are present. It is deliberately NOT a
* mirror of the definitions: those live in `modalRegistry` (module modals) and
* `shellModalDefaults` (shell modals), and `modalsById` below resolves the two
* together.
*/
const modalStateById = ref<Record<string, ModalState>>({});
const modalStack = ref<string[]>([]);
const sidebarMenuCollapsed = useLocalStorage<boolean | null>('sidebar.collapsed', null, {
@ -438,11 +253,23 @@ export const useUIStore = defineStore(STORES.UI, () => {
});
/**
* Modal state by key, for reading. `modalsById` remains the writable state;
* this is the accessor components should read through, because it tolerates a
* key that is not registered.
* The public read surface: every known modal key resolved to its current state
* — its definition's initial state with whatever runtime state has accumulated
* on top. An unknown key reads as closed rather than `undefined`, so a reader
* that runs before its modal registers renders nothing instead of throwing.
*
* This is the single derivation that replaced the store's copy of the registry;
* writes go to `modalStateById` through the actions below.
*/
const modalStateById = computed(() => withFallback(modalsById.value, CLOSED_MODAL_STATE));
const modalsById = computed<Record<string, ModalState>>(() => {
const resolved = modalDefinitionsById(shellModalDefaults);
for (const [key, runtimeState] of Object.entries(modalStateById.value)) {
resolved[key] = key in resolved ? { ...resolved[key], ...runtimeState } : runtimeState;
}
return withFallback(resolved, CLOSED_MODAL_STATE);
});
const isModalActiveById = computed(() =>
withFallback(
@ -503,39 +330,63 @@ export const useUIStore = defineStore(STORES.UI, () => {
applyThemeToBody(newTheme);
};
/**
* Materialize a modal's runtime state on first touch. Only the fields written
* at runtime are stored; the rest is resolved from the definition on read, so
* a key that was never registered still works (dataTable builds per-row keys).
*/
const patchModalState = (name: ModalKey, patch: Partial<ModalState>): void => {
modalStateById.value[name] = {
...modalStateById.value[name],
...patch,
} as ModalState;
};
/** Discard everything runtime has accumulated for a key, open or not. */
const forgetModalState = (name: ModalKey): void => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete modalStateById.value[name];
modalStack.value = modalStack.value.filter((openModalName) => name !== openModalName);
};
/**
* Runtime state lives exactly as long as the definition it accumulated under:
* a modal unregistered while open must not stay open, and must come back in its
* declared initial state if it registers again.
*
* Ad-hoc keys are told apart from unregistered ones by the registry itself —
* only a key that was in it can be taken out of it. dataTable's per-row keys are
* never registered, so they never appear in `previouslyRegistered` and are never
* swept. Same for the shell catalogue, whose definitions are static.
*/
watch(
() => new Set(modalRegistry.getAll().keys()),
(registered, previouslyRegistered) => {
for (const key of previouslyRegistered) {
if (!registered.has(key)) forgetModalState(key);
}
},
{ flush: 'sync' },
);
const setMode = (name: keyof Modals, mode: string): void => {
modalsById.value[name] = {
...modalsById.value[name],
mode,
};
patchModalState(name, { mode });
};
const setActiveId = (name: keyof Modals, activeId: string | null): void => {
modalsById.value[name] = {
...modalsById.value[name],
activeId,
};
patchModalState(name, { activeId });
};
const setShowAuthSelector = (name: keyof Modals, showAuthSelector: boolean): void => {
modalsById.value[name] = {
...modalsById.value[name],
showAuthSelector,
} as NewCredentialsModal;
patchModalState(name, { showAuthSelector } as Partial<NewCredentialsModal>);
};
const setModalData = (payload: { name: keyof Modals; data: Record<string, unknown> }) => {
modalsById.value[payload.name] = {
...modalsById.value[payload.name],
data: payload.data,
};
patchModalState(payload.name, { data: payload.data });
};
const openModal = (name: ModalKey) => {
modalsById.value[name] = {
...modalsById.value[name],
open: true,
};
patchModalState(name, { open: true });
modalStack.value = [name].concat(modalStack.value) as string[];
};
@ -545,10 +396,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
};
const closeModal = (name: ModalKey) => {
modalsById.value[name] = {
...modalsById.value[name],
open: false,
};
patchModalState(name, { open: false });
modalStack.value = modalStack.value.filter((openModalName) => name !== openModalName);
};
@ -567,15 +415,14 @@ export const useUIStore = defineStore(STORES.UI, () => {
) => {
setActiveId(CREDENTIAL_EDIT_MODAL_KEY, id);
setMode(CREDENTIAL_EDIT_MODAL_KEY, 'edit');
modalsById.value[CREDENTIAL_EDIT_MODAL_KEY] = {
...modalsById.value[CREDENTIAL_EDIT_MODAL_KEY],
patchModalState(CREDENTIAL_EDIT_MODAL_KEY, {
projectId: undefined,
contextNode: undefined,
closeOnSave: false,
hideAskAssistant: options.hideAskAssistant,
appendToBody: options.appendToBody,
instanceAiCredentialHelp: options.instanceAiCredentialHelp,
} as NewCredentialsModal;
} as Partial<NewCredentialsModal>);
openModal(CREDENTIAL_EDIT_MODAL_KEY);
};
@ -598,8 +445,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
) => {
setActiveId(CREDENTIAL_EDIT_MODAL_KEY, type);
setShowAuthSelector(CREDENTIAL_EDIT_MODAL_KEY, showAuthOptions);
modalsById.value[CREDENTIAL_EDIT_MODAL_KEY] = {
...modalsById.value[CREDENTIAL_EDIT_MODAL_KEY],
patchModalState(CREDENTIAL_EDIT_MODAL_KEY, {
forceManualMode,
closeOnSave: options.closeOnSave ?? false,
projectId,
@ -611,7 +457,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
instanceAiCredentialHelp: options.instanceAiCredentialHelp,
usageScope: options.usageScope,
credentialSetupHint: options.credentialSetupHint,
} as NewCredentialsModal;
} as Partial<NewCredentialsModal>);
setMode(CREDENTIAL_EDIT_MODAL_KEY, 'new');
openModal(CREDENTIAL_EDIT_MODAL_KEY);
};
@ -721,54 +567,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
hasUnsavedWorkflowChanges.value = false;
};
/**
* 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,
settingsSidebarItems,
@ -824,15 +622,19 @@ export const useUIStore = defineStore(STORES.UI, () => {
moduleTabs,
registerCustomTabs,
registerSettingsPages,
registerModal,
unregisterModal,
initializeModalsFromRegistry,
cleanup,
};
});
/**
* Helper function for listening to model opening and closings in the store
* Listen for modals opening and closing.
*
* Derived from which modals are open, not from which actions were called: there
* is more than one way a modal closes — `closeModal`, and its definition being
* unregistered — and an observer keyed on action names sees only the first. That
* shape breaks silently, with no type error, every time a close path is added.
*
* Returns a stop handle, and is owned by the effect scope it is created in, so a
* caller inside `effectScope()` disposes it by stopping that scope.
*/
export const listenForModalChanges = (opts: {
store: UiStore;
@ -840,34 +642,23 @@ export const listenForModalChanges = (opts: {
onModalClosed?: (name: keyof Modals) => void;
}) => {
const { store, onModalClosed, onModalOpened } = opts;
const listeningForActions = ['openModal', 'openModalWithData', 'closeModal'];
return store.$onAction((result) => {
const { name, after, args } = result;
after(() => {
if (!listeningForActions.includes(name)) {
return;
return watch(
() => store.activeModals,
(openList, previouslyOpenList) => {
// Deduplicated: the same key can sit on the stack more than once (two
// callers opening the credential modal), and that must still read as one
// modal opening once and closing once.
const open = new Set(openList);
const previouslyOpen = new Set(previouslyOpenList);
for (const name of open) {
if (!previouslyOpen.has(name)) onModalOpened?.(name);
}
switch (name) {
case 'openModal': {
const modalName = args[0];
onModalOpened?.(modalName);
break;
}
case 'openModalWithData': {
const { name: modalName } = args[0] ?? {};
onModalOpened?.(modalName);
break;
}
case 'closeModal': {
const modalName = args[0];
onModalClosed?.(modalName);
break;
}
for (const name of previouslyOpen) {
if (!open.has(name)) onModalClosed?.(name);
}
});
});
},
{ flush: 'sync' },
);
};

View File

@ -60,7 +60,7 @@ describe('NodeRecommendationModal', () => {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[EXPERIMENT_TEMPLATE_RECO_V2_KEY]: { open: true },
},
},

View File

@ -182,7 +182,7 @@ describe('AgentToolsConnectionModalWrapper', () => {
workflowsListStore.searchWorkflows = vi.fn().mockResolvedValue([]);
mockedStore(useUsersStore).isAdminOrOwner = true;
uiStore.modalsById = {
uiStore.modalStateById = {
[MODAL_NAME]: { open: true, data: {} },
agentToolConfigModal: { open: false },
};
@ -234,7 +234,7 @@ describe('AgentToolsConnectionModalWrapper', () => {
await flushPromises();
expect(modalAttrs.open).toBe(true);
uiStore.modalsById[MODAL_NAME].open = false;
uiStore.modalStateById[MODAL_NAME].open = false;
await flushPromises();
expect(modalAttrs.open).toBe(false);
});
@ -247,11 +247,11 @@ describe('AgentToolsConnectionModalWrapper', () => {
await flushPromises();
expect(modalAttrs.open).toBe(true);
uiStore.modalsById.agentToolConfigModal.open = true;
uiStore.modalStateById.agentToolConfigModal.open = true;
await flushPromises();
expect(modalAttrs.open).toBe(false);
uiStore.modalsById.agentToolConfigModal.open = false;
uiStore.modalStateById.agentToolConfigModal.open = false;
await flushPromises();
expect(modalAttrs.open).toBe(true);
});

View File

@ -127,7 +127,9 @@ if (isMcpEnabled.value) {
onBeforeUnmount(() => {
const state = uiStore.modalsById[props.modalName];
if (state?.data && Object.keys(state.data).length > 0) {
state.data = {};
// Through the store, not in place: what it resolves is derived state, so an
// assignment onto it is discarded the next time the derivation runs.
uiStore.setModalData({ name: props.modalName, data: {} });
}
});

View File

@ -90,6 +90,7 @@ const { telemetryMock, uiStoreMock } = vi.hoisted(() => ({
instanceAiToolsConnection: { open: true, data: {} },
},
closeModal: vi.fn(),
setModalData: vi.fn(),
openNewCredential: vi.fn(),
openExistingCredential: vi.fn(),
appliedTheme: 'light',
@ -259,6 +260,27 @@ describe('InstanceAiToolsConnectionModalWrapper', () => {
expect(uiStoreMock.closeModal).not.toHaveBeenCalled();
});
// Through the store, because what it resolves is derived state — an assignment
// onto that is discarded, so the next open would reuse the stale connection id.
it('clears the modal data through the store on unmount', () => {
uiStoreMock.modalsById.instanceAiToolsConnection.data = { connectionId: 'conn-1' };
renderComponent().unmount();
expect(uiStoreMock.setModalData).toHaveBeenCalledWith({
name: 'instanceAiToolsConnection',
data: {},
});
});
it('leaves the store alone on unmount when there is no data to clear', () => {
uiStoreMock.modalsById.instanceAiToolsConnection.data = {};
renderComponent().unmount();
expect(uiStoreMock.setModalData).not.toHaveBeenCalled();
});
it('tracks first credential connection start', () => {
renderComponent();

View File

@ -51,7 +51,7 @@ const ModalStub = defineComponent({
const initialState = {
ui: {
modalsById: {
modalStateById: {
[MCP_CONNECT_WORKFLOWS_MODAL_KEY]: {
open: true,
},

View File

@ -24,7 +24,7 @@ const ModalStub = {
const initialState = {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CONFIRM_PASSWORD_MODAL_KEY]: {
open: true,
},

View File

@ -149,7 +149,7 @@ describe('MoveToFolderModal', () => {
beforeEach(() => {
createTestingPinia();
uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[MOVE_FOLDER_MODAL_KEY]: {
open: true,
},

View File

@ -270,7 +270,7 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CREDENTIAL_EDIT_MODAL_KEY]: { open: true },
},
},
@ -345,7 +345,7 @@ describe('CredentialEdit', () => {
const pinia = createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CREDENTIAL_EDIT_MODAL_KEY]: { open: true },
},
},
@ -906,7 +906,7 @@ describe('CredentialEdit', () => {
const pinia = createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: true,
showAuthSelector: true,
@ -1112,7 +1112,7 @@ describe('CredentialEdit', () => {
const pinia = createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CREDENTIAL_EDIT_MODAL_KEY]: { open: true },
},
},
@ -1204,7 +1204,7 @@ describe('CredentialEdit', () => {
createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: true,
showAuthSelector: false,
@ -1584,7 +1584,7 @@ describe('CredentialEdit', () => {
createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[CREDENTIAL_EDIT_MODAL_KEY]: { open: true },
},
},

View File

@ -188,7 +188,7 @@ const ndvStore = computed(() => useNDVStore(workflowDocumentStore.value.document
const contextNode = computed<INode | null>(() => {
if (ndvStore.value.activeNode) return ndvStore.value.activeNode;
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
if (isCredentialModalState(modalState) && modalState.contextNode) {
return modalState.contextNode;
}
@ -197,7 +197,7 @@ const contextNode = computed<INode | null>(() => {
});
const overrideProjectId = computed(() => {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) ? modalState.projectId : undefined;
});
@ -208,11 +208,11 @@ const form = useCredentialForm({
projectId: () => overrideProjectId.value,
showAuthSelector: () => requiredCredentials.value,
suggestedName: () => {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) ? modalState.suggestedName : undefined;
},
setupHint: () => {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) ? modalState.credentialSetupHint : undefined;
},
// Scroll the auth-error/success banner into view after a test (parity with the
@ -259,30 +259,30 @@ const {
} = form;
const hideAskAssistant = computed<boolean>(() => {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) && modalState.hideAskAssistant === true;
});
// The host's Instance AI credential-help behavior, stashed in the modal state by
// whoever opened the modal (the editor capability or the credentials list).
const instanceAiCredentialHelp = computed(() => {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) ? modalState.instanceAiCredentialHelp : undefined;
});
const closeOnSave = computed<boolean>(() => {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) && modalState.closeOnSave === true;
});
const presetUsageScope = computed<NewCredentialsModal['usageScope']>(() => {
if (props.mode !== 'new') return undefined;
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) ? modalState.usageScope : undefined;
});
const appendToBody = computed<boolean>(() => {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) && modalState.appendToBody === true;
});
@ -353,7 +353,7 @@ const showSharingContent = computed(() => activeTab.value === 'sharing' && !!cre
onMounted(async () => {
// Inner try isolates optional secrets loading; outer try catches all other initialization failures.
try {
const modalState = uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY];
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
requiredCredentials.value =
isCredentialModalState(modalState) && modalState.showAuthSelector === true;

View File

@ -130,7 +130,7 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({
const initialState = {
[STORES.UI]: {
modalsById: {
modalStateById: {
[SECRETS_PROVIDER_CONNECTION_MODAL_KEY]: {
open: true,
},

View File

@ -59,7 +59,7 @@ describe('ImportCurlModal', () => {
it('should show empty input when no curl command exists for active node', async () => {
const uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[IMPORT_CURL_MODAL_KEY]: {
open: true,
data: {
@ -82,7 +82,7 @@ describe('ImportCurlModal', () => {
it('should show curl command for active node', async () => {
const uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[IMPORT_CURL_MODAL_KEY]: {
open: true,
data: {
@ -106,7 +106,7 @@ describe('ImportCurlModal', () => {
it('should set the input value when the import button is clicked', async () => {
const uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[IMPORT_CURL_MODAL_KEY]: {
open: true,
data: {
@ -127,15 +127,22 @@ describe('ImportCurlModal', () => {
await userEvent.type(input, 'curl -X GET https://api.example.com/data');
const button = getByTestId('import-curl-modal-button');
await userEvent.click(button);
expect(uiStore.modalsById[IMPORT_CURL_MODAL_KEY].data?.curlCommands).toEqual({
'node-1': 'curl -X GET https://api.example.com/data',
'node-2': 'curl -X POST https://api.example.com/submit',
// Asserted on the write, not on the read: what the store resolves is derived,
// so a component must ask it to store the merged map rather than mutate it.
expect(uiStore.setModalData).toHaveBeenCalledWith({
name: IMPORT_CURL_MODAL_KEY,
data: {
curlCommands: {
'node-1': 'curl -X GET https://api.example.com/data',
'node-2': 'curl -X POST https://api.example.com/submit',
},
},
});
});
it('should override the input value when the import button is clicked', async () => {
const uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[IMPORT_CURL_MODAL_KEY]: {
open: true,
data: {
@ -157,8 +164,9 @@ describe('ImportCurlModal', () => {
await userEvent.type(input, 'curl -X GET https://api.example.com/other');
const button = getByTestId('import-curl-modal-button');
await userEvent.click(button);
expect(uiStore.modalsById[IMPORT_CURL_MODAL_KEY].data?.curlCommands).toEqual({
'node-1': 'curl -X GET https://api.example.com/other',
expect(uiStore.setModalData).toHaveBeenCalledWith({
name: IMPORT_CURL_MODAL_KEY,
data: { curlCommands: { 'node-1': 'curl -X GET https://api.example.com/other' } },
});
});
@ -168,7 +176,7 @@ describe('ImportCurlModal', () => {
});
const uiStore = mockedStore(useUIStore);
uiStore.modalsById = {
uiStore.modalStateById = {
[IMPORT_CURL_MODAL_KEY]: {
open: true,
data: { curlCommands: {} },

View File

@ -23,7 +23,7 @@ const modalBus = createEventBus();
const inputRef = ref<{ focus: () => void; blur: () => void; select: () => void } | null>(null);
onMounted(() => {
const curlCommands = uiStore.modalStateById[IMPORT_CURL_MODAL_KEY].data?.curlCommands as Record<
const curlCommands = uiStore.modalsById[IMPORT_CURL_MODAL_KEY].data?.curlCommands as Record<
string,
string
>;
@ -58,13 +58,14 @@ function onImportFailure(data: { invalidProtocol: boolean; protocol?: string })
function onAfterImport() {
const nodeId = ndvStore.value.activeNode?.id as string;
const curlCommands =
(uiStore.modalStateById[IMPORT_CURL_MODAL_KEY].data?.curlCommands as Record<string, string>) ??
{};
curlCommands[nodeId] = curlCommand.value;
const curlCommands = uiStore.modalsById[IMPORT_CURL_MODAL_KEY].data?.curlCommands as
| Record<string, string>
| undefined;
// Copied rather than mutated: what the store resolves is derived state, and for
// an untouched modal it comes from the shared initial-state catalogue.
uiStore.setModalData({
name: IMPORT_CURL_MODAL_KEY,
data: { curlCommands },
data: { curlCommands: { ...curlCommands, [nodeId]: curlCommand.value } },
});
}

View File

@ -31,7 +31,7 @@ describe('BinaryDataViewModal.vue', () => {
pinia: createTestingPinia({
initialState: {
ui: {
modalsById: {
modalStateById: {
[BINARY_DATA_VIEW_MODAL_KEY]: {
open: true,
},

View File

@ -37,7 +37,7 @@ const renderComponent = createComponentRenderer(ApiKeyEditModal, {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: { open: true },
},
},

View File

@ -39,7 +39,7 @@ const renderComponent = (modalData = {}) => {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: {
open: true,
data: modalData,

View File

@ -25,7 +25,7 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: { open: true },
},
},

View File

@ -51,7 +51,7 @@ const mockVariables: EnvironmentVariable[] = [
const initialState = {
[STORES.UI]: {
modalsById: {
modalStateById: {
[VARIABLE_MODAL_KEY]: {
open: true,
},

View File

@ -62,7 +62,7 @@ const mockUsersList: UsersList = {
const initialState = {
[STORES.UI]: {
modalsById: {
modalStateById: {
[DELETE_USER_MODAL_KEY]: {
open: true,
},

View File

@ -15,8 +15,8 @@ import { PERSONALIZATION_MODAL_KEY } from './users.constants';
* simply never appears. This drives the real wiring from `init.ts` end to end so
* that divergence fails here instead. (N8N-126)
*
* The complementary check — every key used in `Modals.vue` is registered in
* `modalsById` — lives in `ui.store.registration.spec.ts`.
* The complementary check — every key a `<ModalRoot>` renders has a definition
* backing it — lives in `ui.store.registration.spec.ts`.
*/
describe('PERSONALIZATION_MODAL_KEY', () => {
beforeEach(() => {

View File

@ -408,7 +408,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockResolvedValue($input);
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open = true;
uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY] = { open: true };
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,
@ -431,7 +431,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockResolvedValue($input);
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open = true;
uiStore.modalStateById[CREDENTIAL_EDIT_MODAL_KEY] = { open: true };
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,

View File

@ -229,8 +229,7 @@ export async function resolveAutocompleteExpression(
// state-based utils
// ----------------------------------
export const isCredentialsModalOpen = () =>
useUIStore().modalStateById[CREDENTIAL_EDIT_MODAL_KEY].open;
export const isCredentialsModalOpen = () => useUIStore().modalsById[CREDENTIAL_EDIT_MODAL_KEY].open;
export const isInHttpNodePagination = (
workflowDocumentId: WorkflowDocumentId,

View File

@ -14,7 +14,7 @@ const renderComponent = createComponentRenderer(WorkflowVersionFormModal, {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
modalStateById: {
[TEST_MODAL_KEY]: {
open: true,
},