diff --git a/packages/frontend/@n8n/frontend-module-sdk/package.json b/packages/frontend/@n8n/frontend-module-sdk/package.json index 642fdfcf4c1..237753474c8 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/package.json +++ b/packages/frontend/@n8n/frontend-module-sdk/package.json @@ -20,6 +20,8 @@ "dependencies": { "@n8n/api-types": "workspace:*", "@n8n/design-system": "workspace:*", + "@n8n/utils": "workspace:*", + "n8n-workflow": "workspace:*", "vue": "catalog:frontend", "vue-router": "catalog:frontend" }, diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/index.ts b/packages/frontend/@n8n/frontend-module-sdk/src/index.ts index b793942555f..c5423a30cc2 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/index.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/index.ts @@ -6,3 +6,4 @@ export * as modalRegistry from './registries/modalRegistry'; export * from './registries/resourceRegistry'; export * as pushHandlerRegistry from './registries/pushHandlerRegistry'; export * as commandRegistry from './registries/commandRegistry'; +export * as parameterInputRegistry from './registries/parameterInputRegistry'; diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/registries/parameterInputRegistry.test.ts b/packages/frontend/@n8n/frontend-module-sdk/src/registries/parameterInputRegistry.test.ts new file mode 100644 index 00000000000..1d29508d441 --- /dev/null +++ b/packages/frontend/@n8n/frontend-module-sdk/src/registries/parameterInputRegistry.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { computed } from 'vue'; +import type { Component } from 'vue'; + +import * as parameterInputRegistry from './parameterInputRegistry'; +import type { ParameterInputContribution, ParameterInputType } from '../types/parameterInput'; + +describe('parameterInputRegistry', () => { + const resourceLocatorComponent = { name: 'TestResourceLocator' } as Component; + const workflowSelectorComponent = { name: 'TestWorkflowSelector' } as Component; + const asyncComponent = async (): Promise => + await Promise.resolve({ name: 'AsyncTestInput' } as Component); + + const resourceLocator: ParameterInputContribution = { + type: 'resourceLocator', + component: resourceLocatorComponent, + capabilities: { ownsExpressionRendering: true, ownsFromAiOverride: true, disableDrop: true }, + }; + + const workflowSelector: ParameterInputContribution = { + type: 'workflowSelector', + component: workflowSelectorComponent, + }; + + const lazyInput: ParameterInputContribution = { + type: 'filter', + component: asyncComponent, + }; + + beforeEach(() => { + parameterInputRegistry.clear(); + }); + + describe('register', () => { + it('should register a contribution under its parameter type', () => { + parameterInputRegistry.register(resourceLocator); + + expect(parameterInputRegistry.has('resourceLocator')).toBe(true); + expect(parameterInputRegistry.get('resourceLocator')).toEqual(resourceLocator); + }); + + it('should register multiple contributions', () => { + parameterInputRegistry.register(resourceLocator); + parameterInputRegistry.register(workflowSelector); + + expect(parameterInputRegistry.getAll().size).toBe(2); + }); + + it('should accept a lazy component factory', () => { + parameterInputRegistry.register(lazyInput); + + expect(parameterInputRegistry.get('filter')?.component).toBe(asyncComponent); + }); + + it('should warn and skip when a different contribution claims a taken type', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + parameterInputRegistry.register(resourceLocator); + parameterInputRegistry.register({ + type: 'resourceLocator', + component: workflowSelectorComponent, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Parameter input for type "resourceLocator" is already registered. Skipping.', + ); + expect(parameterInputRegistry.get('resourceLocator')?.component).toBe( + resourceLocatorComponent, + ); + + consoleSpy.mockRestore(); + }); + + it('should treat a replay of the same contribution as a no-op', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + parameterInputRegistry.register(resourceLocator); + parameterInputRegistry.register(resourceLocator); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(parameterInputRegistry.getAll().size).toBe(1); + + consoleSpy.mockRestore(); + }); + }); + + describe('get', () => { + it('should return undefined for an unregistered type', () => { + parameterInputRegistry.register(resourceLocator); + + expect(parameterInputRegistry.get('string')).toBeUndefined(); + expect(parameterInputRegistry.has('string')).toBe(false); + }); + + it('should return undefined once every contribution is cleared', () => { + parameterInputRegistry.register(resourceLocator); + parameterInputRegistry.clear(); + + expect(parameterInputRegistry.get('resourceLocator')).toBeUndefined(); + }); + }); + + describe('unregister', () => { + it('should remove a registered type', () => { + parameterInputRegistry.register(resourceLocator); + parameterInputRegistry.unregister('resourceLocator'); + + expect(parameterInputRegistry.has('resourceLocator')).toBe(false); + }); + + it('should let the type be claimed again after removal', () => { + parameterInputRegistry.register(resourceLocator); + parameterInputRegistry.unregister('resourceLocator'); + parameterInputRegistry.register({ + type: 'resourceLocator', + component: workflowSelectorComponent, + }); + + expect(parameterInputRegistry.get('resourceLocator')?.component).toBe( + workflowSelectorComponent, + ); + }); + }); + + describe('getAll', () => { + it('should return a copy, so a caller cannot mutate the registry', () => { + parameterInputRegistry.register(resourceLocator); + + parameterInputRegistry.getAll().delete('resourceLocator'); + + expect(parameterInputRegistry.has('resourceLocator')).toBe(true); + }); + }); + + describe('reactivity', () => { + it('should re-evaluate a computed when a contribution is registered', () => { + const resolved = computed(() => parameterInputRegistry.get('resourceLocator')); + + expect(resolved.value).toBeUndefined(); + + parameterInputRegistry.register(resourceLocator); + + expect(resolved.value).toEqual(resourceLocator); + }); + + it('should not wrap the component in a reactive proxy', () => { + parameterInputRegistry.register(resourceLocator); + + // Shallow registry: a reactive component logs "Vue received a Component + // that was made a reactive object" and breaks ``. + expect(parameterInputRegistry.get('resourceLocator')?.component).toBe( + resourceLocatorComponent, + ); + }); + }); + + describe('subscribe', () => { + it('should notify listeners on register and unregister', () => { + const listener = + vi.fn<(entries: Map) => void>(); + const unsubscribe = parameterInputRegistry.subscribe(listener); + + parameterInputRegistry.register(resourceLocator); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0]?.[0].get('resourceLocator')).toEqual(resourceLocator); + + parameterInputRegistry.unregister('resourceLocator'); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + parameterInputRegistry.register(workflowSelector); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('should not notify when unregistering a type that was never claimed', () => { + const listener = vi.fn(); + const unsubscribe = parameterInputRegistry.subscribe(listener); + + parameterInputRegistry.unregister('string'); + + expect(listener).not.toHaveBeenCalled(); + + unsubscribe(); + }); + }); +}); diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/registries/parameterInputRegistry.ts b/packages/frontend/@n8n/frontend-module-sdk/src/registries/parameterInputRegistry.ts new file mode 100644 index 00000000000..314bd3f5eb7 --- /dev/null +++ b/packages/frontend/@n8n/frontend-module-sdk/src/registries/parameterInputRegistry.ts @@ -0,0 +1,72 @@ +import { shallowReactive } from 'vue'; + +import type { ParameterInputContribution, ParameterInputType } from '../types/parameterInput'; + +/** + * Shallow-reactive so the render path can derive from the registry with a plain + * `computed`. Shallow on purpose: a contribution's `component` must not be + * turned into a reactive object. + */ +const parameterInputs = shallowReactive(new Map()); +const listeners = new Set<(entries: Map) => void>(); + +export function getAll(): Map { + return new Map(parameterInputs); +} + +function notifyListeners(): void { + listeners.forEach((listener) => listener(getAll())); +} + +/** + * Claim `contribution.type` for this component. One owner per type: the shell's + * built-in branch for that type is what an unclaimed type falls back to. + */ +export function register(contribution: ParameterInputContribution): void { + const existing = parameterInputs.get(contribution.type); + if (existing) { + // Replaying the same contribution is how a re-login re-runs registration — + // a no-op, not a collision. Only a different contribution claiming a taken + // type is worth warning about. + if (existing !== contribution) { + console.warn( + `Parameter input for type "${contribution.type}" is already registered. Skipping.`, + ); + } + return; + } + + parameterInputs.set(contribution.type, contribution); + notifyListeners(); +} + +export function unregister(type: ParameterInputType): void { + if (parameterInputs.delete(type)) { + notifyListeners(); + } +} + +export function get(type: ParameterInputType): ParameterInputContribution | undefined { + return parameterInputs.get(type); +} + +export function has(type: ParameterInputType): boolean { + return parameterInputs.has(type); +} + +export function subscribe( + listener: (entries: Map) => void, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Remove all registered parameter inputs. Primarily for test isolation. + */ +export function clear(): void { + parameterInputs.clear(); + notifyListeners(); +} diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.test.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.test.ts index a39d39e0af7..1aaae4f8590 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.test.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.test.ts @@ -43,4 +43,23 @@ describe('FrontendModuleDescription', () => { expect(descriptor.commands?.[0]?.id).toBe('v2.open'); expect(descriptor.pushHandlers?.workflowActivated).toBeTypeOf('function'); }); + + it('accepts a parameter-input contribution with a lazy component', () => { + const descriptor: FrontendModuleDescription = { + id: 'inputs', + name: 'Inputs', + description: 'A descriptor contributing a parameter input', + icon: 'box', + parameterInputs: [ + { + type: 'resourceLocator', + component: async () => await Promise.resolve({ name: 'ResourceLocator' }), + capabilities: { ownsExpressionRendering: true, disableDrop: true }, + }, + ], + }; + + expect(descriptor.parameterInputs?.[0]?.type).toBe('resourceLocator'); + expect(descriptor.parameterInputs?.[0]?.capabilities?.ownsFromAiOverride).toBeUndefined(); + }); }); diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts index 099fca57c6b..3881fe401df 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts @@ -5,6 +5,7 @@ import type { ModuleBanner } from './banner'; import type { CommandBarEntry } from './command'; import type { ModuleLocaleMessages } from './locale'; import type { ModalDefinition } from './modal'; +import type { ParameterInputContribution } from './parameterInput'; import type { ModulePushHandlers } from './push'; import type { ResourceMetadata } from './resource'; import type { ModuleSetupContext } from './setup'; @@ -48,6 +49,12 @@ export type FrontendModuleDescription = { commands?: CommandBarEntry[]; /** Global keyboard shortcuts. */ shortcuts?: ModuleShortcut[]; + /** + * Custom parameter input components, resolved by `parameter.type` in the NDV + * parameter render path. One owner per type; an unclaimed type keeps the + * shell's built-in branch. + */ + parameterInputs?: ParameterInputContribution[]; /** Banners the module can contribute to the banner stack. */ banners?: ModuleBanner[]; /** Runs post-login, after the module is confirmed active. */ diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts index 525c9119ea9..13c9b1405ef 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts @@ -1,5 +1,6 @@ export type * from './descriptor'; export type * from './modal'; +export type * from './parameterInput'; export type * from './resource'; export type * from './tabs'; export type * from './push'; diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/parameterInput.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/parameterInput.ts new file mode 100644 index 00000000000..b835319bf50 --- /dev/null +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/parameterInput.ts @@ -0,0 +1,99 @@ +import type { EventBus } from '@n8n/utils/event-bus'; +import type { + INode, + INodeProperties, + NodeParameterValueType, + NodePropertyTypes, +} from 'n8n-workflow'; +import type { Component } from 'vue'; + +/** + * The `parameter.type` value a module claims. Deliberately the closed + * `NodePropertyTypes` union: a key the frontend accepts but a node author cannot + * declare would be a false surface. Widening the union later is additive. + */ +export type ParameterInputType = NodePropertyTypes; + +/** + * Props the shell passes to every contributed parameter input. The names and + * types match what `ParameterInput.vue` already passes to its built-in + * resource-locator branch, so an extracted component needs no adapter. + */ +export type ParameterInputProps = { + parameter: INodeProperties; + modelValue: NodeParameterValueType; + path: string; + node?: INode; + displayTitle: string; + isReadOnly: boolean; + isValueExpression: boolean; + expressionDisplayValue: string; + expressionComputedValue: unknown; + /** `undefined` until the async resolution of `loadOptionsDependsOn` settles. */ + dependentParametersValues?: string | null; + /** + * The parameter's validation issues, for an input that wants to place them + * itself. The shell also draws `ParameterIssues` below the input for every + * type outside the resource-locator family, so an input that renders these + * shows them twice. + */ + parameterIssues: string[]; + droppable: boolean; + eventBus?: EventBus; +}; + +/** Events the shell listens for on a contributed parameter input. */ +export type ParameterInputEmits = { + // Vue fixes the v-model event name; it cannot be camelCase. + // eslint-disable-next-line @typescript-eslint/naming-convention + 'update:modelValue': [value: NodeParameterValueType]; + modalOpenerClick: []; + focus: []; + blur: []; + drop: [value: string]; +}; + +/** + * Capabilities the input takes over from `ParameterInputFull` / `ParameterInputWrapper`. + * + * These exist because `parameter.type` drives behaviour outside the render + * branch too — the expression toggle, the drop target and the from-AI override. + * An input that wins only the render branch would render correctly and still get + * the wrong capabilities. Every default keeps today's behaviour, so an input that + * needs none of it declares nothing. + */ +export type ParameterInputCapabilities = { + /** + * The input draws its own expression editor, so the shell does not swap in + * `ExpressionParameterInput` and does not show the expression selector for a + * parameter whose modes are list-only. + */ + ownsExpressionRendering?: boolean; + /** The input owns the from-AI override, so the shell hides its own toggle. */ + ownsFromAiOverride?: boolean; + /** The shell refuses drag-and-drop onto the field. */ + disableDrop?: boolean; +}; + +/** + * A component the shell can drive with `ParameterInputProps`. Typing the + * contribution against it makes the prop contract a compiler check rather than + * a comment: a component declaring an incompatible prop is rejected at the + * registry boundary. + */ +export type ParameterInputComponent = Component; + +export type ParameterInputContribution = { + /** The `parameter.type` this entry renders. Also the registry key. */ + type: ParameterInputType; + /** + * Lazy on purpose: a `() => import()` keeps the module's `*.module.ts` + * import-light (design §5.2) and keeps the component out of the shell chunk. + * + * A bare function here is read as the loader, following `ModalDefinition`. So a + * functional component must be wrapped (`defineComponent(fn)`) or it is called + * as a loader and never renders. + */ + component: ParameterInputComponent | (() => Promise); + capabilities?: ParameterInputCapabilities; +}; diff --git a/packages/frontend/editor-ui/src/app/init/index.test.ts b/packages/frontend/editor-ui/src/app/init/index.test.ts index 174ce3a5367..130cf463eb3 100644 --- a/packages/frontend/editor-ui/src/app/init/index.test.ts +++ b/packages/frontend/editor-ui/src/app/init/index.test.ts @@ -40,6 +40,7 @@ vi.mock('@/app/moduleInitializer/moduleInitializer', async (importOriginal) => { registerModuleSettingsPages: vi.fn(actual.registerModuleSettingsPages), registerModulePushHandlers: vi.fn(actual.registerModulePushHandlers), registerModuleCommands: vi.fn(actual.registerModuleCommands), + registerModuleParameterInputs: vi.fn(actual.registerModuleParameterInputs), }; }); @@ -50,6 +51,7 @@ const moduleRegistrations = [ moduleInitializer.registerModuleSettingsPages, moduleInitializer.registerModulePushHandlers, moduleInitializer.registerModuleCommands, + moduleInitializer.registerModuleParameterInputs, ]; vi.mock('@n8n/composables/useToast', () => ({ diff --git a/packages/frontend/editor-ui/src/app/init/index.ts b/packages/frontend/editor-ui/src/app/init/index.ts index 60efab12b61..e6396f743ff 100644 --- a/packages/frontend/editor-ui/src/app/init/index.ts +++ b/packages/frontend/editor-ui/src/app/init/index.ts @@ -11,6 +11,7 @@ import type { AuthenticationMethod } from '@n8n/api-types'; import { registerModuleCommands, registerModuleModals, + registerModuleParameterInputs, registerModuleProjectTabs, registerModulePushHandlers, registerModuleResources, @@ -241,6 +242,7 @@ export async function initializeAuthenticatedFeatures( registerModuleSettingsPages(); registerModulePushHandlers(); registerModuleCommands(); + registerModuleParameterInputs(); // Initialize run data worker and load node types if (isDataWorkerEnabled()) { diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.parameterInputs.test.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.parameterInputs.test.ts new file mode 100644 index 00000000000..d0544a39b7d --- /dev/null +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.parameterInputs.test.ts @@ -0,0 +1,73 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { parameterInputRegistry } from '@n8n/frontend-module-sdk'; +import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk'; +import { useSettingsStore } from '@n8n/stores/settings.store'; +import merge from 'lodash/merge'; + +import { defaultSettings } from '@n8n/frontend-test-utils'; + +// A stand-in manifest: no shipped module contributes a parameter input yet, and +// the point of these tests is the registration rule, not the current manifest. +const contributingModule: FrontendModuleDescription = { + id: 'inputs', + name: 'Inputs', + description: '', + icon: 'box', + parameterInputs: [ + { + type: 'resourceLocator', + component: { render: () => null }, + capabilities: { ownsExpressionRendering: true }, + }, + ], +}; + +vi.mock('@/app/modules.manifest', () => ({ + modules: [contributingModule], +})); + +const { registerModuleParameterInputs } = await import('@/app/moduleInitializer/moduleInitializer'); + +describe('registerModuleParameterInputs', () => { + const setActiveModules = (activeModules: string[]) => { + useSettingsStore().setSettings(merge({}, defaultSettings, { activeModules })); + }; + + beforeEach(() => { + setActivePinia(createPinia()); + parameterInputRegistry.clear(); + }); + + it('registers a contribution under the type its descriptor claims', () => { + setActiveModules(['inputs']); + + registerModuleParameterInputs(); + + expect(parameterInputRegistry.get('resourceLocator')?.capabilities).toEqual({ + ownsExpressionRendering: true, + }); + }); + + // A parameter input is a render primitive, not a feature: gating it would + // leave the parameter with nothing to render it. + it('registers even when the module is not active', () => { + setActiveModules([]); + + registerModuleParameterInputs(); + + expect(parameterInputRegistry.has('resourceLocator')).toBe(true); + }); + + it('replays registration without warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setActiveModules(['inputs']); + + registerModuleParameterInputs(); + registerModuleParameterInputs(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(parameterInputRegistry.getAll().size).toBe(1); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts index 21ec1634250..ae8868f781f 100644 --- a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts @@ -5,6 +5,7 @@ import { registerResource, pushHandlerRegistry, commandRegistry, + parameterInputRegistry, } from '@n8n/frontend-module-sdk'; import { VIEWS } from '@/app/constants'; import { modules } from '@/app/modules.manifest'; @@ -127,6 +128,23 @@ export const registerModuleCommands = () => { }); }; +/** + * Initialize module parameter inputs, done in init.ts. `ParameterInput` resolves + * them by `parameter.type` at render time. + * + * Deliberately NOT gated on `isModuleActive`: a parameter input is a render + * primitive, not a feature. A gated renderer leaves a parameter with nothing to + * render it, which is a broken field rather than a hidden feature. Availability + * is enforced backend-side. + */ +export const registerModuleParameterInputs = () => { + modules.forEach((module) => { + module.parameterInputs?.forEach((contribution) => { + parameterInputRegistry.register(contribution); + }); + }); +}; + /** * Initialize module routes, done in main.ts */ diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.test.ts b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.test.ts index d90c1de64e4..4c319a0999b 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.test.ts @@ -1,4 +1,4 @@ -import { computed, defineComponent, nextTick, reactive, shallowRef } from 'vue'; +import { computed, defineComponent, h, nextTick, reactive, shallowRef } from 'vue'; import { createComponentRenderer } from '@/__tests__/render'; import { ToolConfigCredentialSelectedKey, @@ -23,7 +23,13 @@ import { createTestNodeProperties, } from '@/__tests__/mocks'; import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; -import { type INodeParameterResourceLocator, type INodePropertyOptions } from 'n8n-workflow'; +import { + type INode, + type INodeParameterResourceLocator, + type INodeProperties, + type INodePropertyOptions, + type NodeParameterValueType, +} from 'n8n-workflow'; import type { INodeUpdatePropertiesInformation, IWorkflowDb, @@ -31,6 +37,9 @@ import type { } from '@/Interface'; import { mock } from 'vitest-mock-extended'; import { ExpressionLocalResolveContextSymbol } from '@/app/constants'; +import { parameterInputRegistry } from '@n8n/frontend-module-sdk'; +import type { PropType } from 'vue'; +import type { EventBus } from '@n8n/utils/event-bus'; function getNdvStateMock(): Partial> { return { @@ -1804,4 +1813,147 @@ describe('ParameterInput.vue', () => { expect(onToolConfigCredentialSelected).toHaveBeenCalledWith(credentialUpdate); }); }); + + describe('module-contributed parameter inputs', () => { + // The whole contract, spelled out because a `.ts` file has no SFC compiler. + // A module writes `defineProps()` in `