mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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
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:
parent
20e331c867
commit
8b22cca3a0
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const renderComponent = createComponentRenderer(ChatEmbedModal, {
|
|||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[CHAT_EMBED_MODAL_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ describe('FromAiParametersModal', () => {
|
|||
stubActions: false,
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[FROM_AI_PARAMETERS_MODAL_KEY]: {
|
||||
open: true,
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const initialState = {
|
|||
},
|
||||
},
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[WORKFLOW_PUBLISH_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe('VersionUpdateCTA', () => {
|
|||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.modalsById = {
|
||||
uiStore.modalStateById = {
|
||||
[WHATS_NEW_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe('WhatsNewModal', () => {
|
|||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.modalsById = {
|
||||
uiStore.modalStateById = {
|
||||
[WHATS_NEW_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -89,8 +89,6 @@ export const registerModuleModals = () => {
|
|||
modalRegistry.register(modalDef);
|
||||
});
|
||||
});
|
||||
// Subscribe to modal registry changes
|
||||
useUIStore().initializeModalsFromRegistry();
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
239
packages/frontend/editor-ui/src/app/stores/defaults/modals.ts
Normal file
239
packages/frontend/editor-ui/src/app/stores/defaults/modals.ts
Normal 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 },
|
||||
});
|
||||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ describe('NodeRecommendationModal', () => {
|
|||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[EXPERIMENT_TEMPLATE_RECO_V2_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {} });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const ModalStub = defineComponent({
|
|||
|
||||
const initialState = {
|
||||
ui: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[MCP_CONNECT_WORKFLOWS_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const ModalStub = {
|
|||
|
||||
const initialState = {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[CONFIRM_PASSWORD_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ describe('MoveToFolderModal', () => {
|
|||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.modalsById = {
|
||||
uiStore.modalStateById = {
|
||||
[MOVE_FOLDER_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({
|
|||
|
||||
const initialState = {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[SECRETS_PROVIDER_CONNECTION_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe('BinaryDataViewModal.vue', () => {
|
|||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
ui: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[BINARY_DATA_VIEW_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const renderComponent = createComponentRenderer(ApiKeyEditModal, {
|
|||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const renderComponent = (modalData = {}) => {
|
|||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: {
|
||||
open: true,
|
||||
data: modalData,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod
|
|||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const mockVariables: EnvironmentVariable[] = [
|
|||
|
||||
const initialState = {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[VARIABLE_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const mockUsersList: UsersList = {
|
|||
|
||||
const initialState = {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[DELETE_USER_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const renderComponent = createComponentRenderer(WorkflowVersionFormModal, {
|
|||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
modalStateById: {
|
||||
[TEST_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user