feat(editor): Let frontend modules register parameter input types (no-changelog) (#37100)

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Alex Grozav
2026-09-06 10:24:24 +00:00
committed by GitHub
co-authored by Claude Opus 5 multica-agent
parent 7cb77fbed6
commit 95ee3f4d91
19 changed files with 974 additions and 9 deletions
@@ -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"
},
@@ -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';
@@ -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<Component> =>
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 `<component :is>`.
expect(parameterInputRegistry.get('resourceLocator')?.component).toBe(
resourceLocatorComponent,
);
});
});
describe('subscribe', () => {
it('should notify listeners on register and unregister', () => {
const listener =
vi.fn<(entries: Map<ParameterInputType, ParameterInputContribution>) => 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();
});
});
});
@@ -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<ParameterInputType, ParameterInputContribution>());
const listeners = new Set<(entries: Map<ParameterInputType, ParameterInputContribution>) => void>();
export function getAll(): Map<ParameterInputType, ParameterInputContribution> {
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<ParameterInputType, ParameterInputContribution>) => 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();
}
@@ -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();
});
});
@@ -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. */
@@ -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';
@@ -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<ParameterInputProps>;
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<ParameterInputComponent>);
capabilities?: ParameterInputCapabilities;
};
@@ -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', () => ({
@@ -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()) {
@@ -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();
});
});
@@ -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
*/
@@ -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<ReturnType<typeof useNDVStore>> {
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<ParameterInputProps>()` in `<script setup>`
// and gets the same runtime props generated for it.
const contractProps = {
parameter: { type: Object as PropType<INodeProperties>, required: true as const },
modelValue: {
type: null as unknown as PropType<NodeParameterValueType>,
required: true as const,
},
path: { type: String, required: true as const },
node: { type: Object as PropType<INode>, required: false as const },
displayTitle: { type: String, required: true as const },
isReadOnly: { type: Boolean, required: true as const },
isValueExpression: { type: Boolean, required: true as const },
expressionDisplayValue: { type: String, required: true as const },
expressionComputedValue: {
type: null as unknown as PropType<unknown>,
required: true as const,
},
dependentParametersValues: {
type: String as unknown as PropType<string | null>,
required: false as const,
},
parameterIssues: { type: Array as PropType<string[]>, required: true as const },
droppable: { type: Boolean, required: true as const },
eventBus: { type: Object as PropType<EventBus>, required: false as const },
};
const ContributedInput = defineComponent({
props: contractProps,
emits: ['update:modelValue'],
setup:
(props, { emit }) =>
() =>
h(
'button',
{
'data-test-id': 'contributed-input',
onClick: () => emit('update:modelValue', 'from-module'),
},
String(props.modelValue),
),
});
const stringParameter = {
displayName: 'Custom',
name: 'custom',
type: 'string' as const,
default: '',
};
afterEach(() => {
parameterInputRegistry.clear();
});
test('should render the built-in input when no module claims the type', () => {
const { container, queryByTestId } = renderComponent({
props: { path: 'custom', parameter: stringParameter, modelValue: 'built-in' },
});
expect(queryByTestId('contributed-input')).not.toBeInTheDocument();
expect(container.querySelector('input')).toBeInTheDocument();
});
test('should render a contributed input for the claimed type', async () => {
parameterInputRegistry.register({ type: 'string', component: ContributedInput });
const { getByTestId } = renderComponent({
props: { path: 'custom', parameter: stringParameter, modelValue: 'from-shell' },
});
await nextTick();
expect(getByTestId('contributed-input')).toHaveTextContent('from-shell');
});
test('should emit the parameter update a contributed input sends', async () => {
parameterInputRegistry.register({ type: 'string', component: ContributedInput });
const { getByTestId, emitted } = renderComponent({
props: { path: 'custom', parameter: stringParameter, modelValue: '' },
});
await nextTick();
await fireEvent.click(getByTestId('contributed-input'));
await waitFor(() => expect(emitted().update).toBeTruthy());
expect(emitted().update[0]).toEqual([
expect.objectContaining({ name: 'custom', value: 'from-module' }),
]);
});
test('should leave another type on its built-in branch', async () => {
parameterInputRegistry.register({ type: 'string', component: ContributedInput });
const { container, queryByTestId } = renderComponent({
props: {
path: 'count',
parameter: { displayName: 'Count', name: 'count', type: 'number', default: 0 },
modelValue: 3,
},
});
await nextTick();
expect(queryByTestId('contributed-input')).not.toBeInTheDocument();
expect(container.querySelector('input')).toBeInTheDocument();
});
test('should yield to the expression editor when the input does not own expression rendering', async () => {
parameterInputRegistry.register({ type: 'string', component: ContributedInput });
const { queryByTestId } = renderComponent({
props: {
path: 'custom',
parameter: stringParameter,
modelValue: '={{ $json.value }}',
},
});
await nextTick();
expect(queryByTestId('contributed-input')).not.toBeInTheDocument();
});
test('should keep an expression value when the input owns expression rendering', async () => {
parameterInputRegistry.register({
type: 'string',
component: ContributedInput,
capabilities: { ownsExpressionRendering: true },
});
const { getByTestId } = renderComponent({
props: {
path: 'custom',
parameter: stringParameter,
modelValue: '={{ $json.value }}',
},
});
await nextTick();
expect(getByTestId('contributed-input')).toBeInTheDocument();
});
});
});
@@ -52,6 +52,7 @@ import {
shouldSkipParamValidation,
} from '@/features/ndv/shared/ndv.utils';
import { hasExpressionMapping, isValueExpression } from '@/app/utils/nodeTypesUtils';
import { useParameterInputContribution } from '@/features/ndv/parameters/composables/useParameterInputContribution';
import {
AI_TRANSFORM_NODE_TYPE,
@@ -270,6 +271,22 @@ const isResourceLocatorParameter = computed<boolean>(() => {
return isResourceLocatorParameterType(props.parameter.type);
});
const parameterType = computed(() => props.parameter.type);
const { contributedComponent, capabilities: contributedCapabilities } =
useParameterInputContribution(parameterType);
/**
* A contributed input that owns expression rendering replaces every built-in
* branch. One that does not yields to the expression editor first, exactly as a
* built-in non-resource-locator type does.
*/
const showContributedComponent = computed<boolean>(() => {
if (!contributedComponent.value) return false;
if (contributedCapabilities.value.ownsExpressionRendering) return true;
return !isModelValueExpression.value && !props.forceShowExpression;
});
const isSecretParameter = computed<boolean>(() => {
return getTypeOption('password') === true;
});
@@ -772,7 +789,7 @@ const isDropDisabled = computed(
() =>
props.parameter.noDataExpression === true ||
props.isReadOnly ||
isResourceLocatorParameter.value ||
contributedCapabilities.value.disableDrop ||
isModelValueExpression.value,
);
const showDragnDropTip = computed(
@@ -1521,8 +1538,30 @@ onUpdated(async () => {
:style="parameterInputWrapperStyle"
:data-parameter-path="path"
>
<component
:is="contributedComponent"
v-if="showContributedComponent"
:parameter="parameter"
:model-value="modelValue"
:path="path"
:node="node"
:display-title="displayTitle"
:is-read-only="isReadOnly"
:is-value-expression="isModelValueExpression"
:expression-display-value="expressionDisplayValue"
:expression-computed-value="expressionEvaluated"
:dependent-parameters-values="dependentParametersValues"
:parameter-issues="getIssues"
:droppable="droppable ?? false"
:event-bus="eventBus"
@update:model-value="valueChangedDebounced"
@modal-opener-click="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@drop="onResourceLocatorDrop"
/>
<ResourceLocator
v-if="parameter.type === 'resourceLocator'"
v-else-if="parameter.type === 'resourceLocator'"
ref="resourceLocator"
:parameter="parameter"
:model-value="modelValueResourceLocator"
@@ -8,6 +8,7 @@ import { FROM_AI_AUTO_GENERATED_MARKER } from 'n8n-workflow';
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestNodeProperties } from '@/__tests__/mocks';
import { parameterInputRegistry } from '@n8n/frontend-module-sdk';
// Instantiates a store that derives the workflow id from the route. These tests run
// without a router, so resolve the id directly.
@@ -250,4 +251,120 @@ describe('ParameterInputFull.vue', () => {
expect(emitted().hover).toEqual([[true], [false]]);
});
// Guards the three capability reads in this component. Each test pairs a baseline
// with the flag set, so reverting a read back to the literal
// `isResourceLocator` list flips the flagged case and fails here.
describe('module-contributed capabilities', () => {
const contributedInput = { render: () => null };
const asAiToolNode = () => {
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
codex: { categories: ['AI'], subcategories: { AI: ['Tools'] } },
});
};
afterEach(() => {
parameterInputRegistry.clear();
});
describe('ownsFromAiOverride', () => {
it('offers the from-AI override to a contributed input that does not claim it', () => {
asAiToolNode();
parameterInputRegistry.register({ type: 'string', component: contributedInput });
const { getByTestId } = renderComponent();
expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
});
it('hides the shell override when the contributed input owns it', () => {
asAiToolNode();
parameterInputRegistry.register({
type: 'string',
component: contributedInput,
capabilities: { ownsFromAiOverride: true },
});
const { queryByTestId } = renderComponent();
expect(queryByTestId('from-ai-override-button')).not.toBeInTheDocument();
});
});
describe('disableDrop', () => {
// `DraggableTarget` only reports `droppable` mid-drag, and that flag reaches
// the DOM as the `droppable` class on the parameter input.
const startMappingDrag = () => {
mockNdvState.isDraggableDragging = true;
mockNdvState.draggableType = 'mapping';
mockNdvState.draggable = {
isDragging: true,
type: 'mapping',
data: '',
dimensions: { width: 0, height: 0 },
activeTarget: null,
stickyPosition: null,
} as never;
mockNdvState.setDraggableTarget = vi.fn();
};
it('accepts a drop for a contributed input that does not disable it', () => {
startMappingDrag();
parameterInputRegistry.register({ type: 'string', component: contributedInput });
const { container } = renderComponent();
expect(container.querySelector('.droppable')).toBeInTheDocument();
});
it('refuses the drop when the contributed input disables it', () => {
startMappingDrag();
parameterInputRegistry.register({
type: 'string',
component: contributedInput,
capabilities: { disableDrop: true },
});
const { container } = renderComponent();
expect(container.querySelector('.droppable')).not.toBeInTheDocument();
});
});
describe('ownsExpressionRendering', () => {
// A list-only parameter is where the two paths diverge: the flagged path
// derives the selector from the modes, the default path does not look at them.
const listOnlyParameter = createTestNodeProperties({
displayName: 'My Param',
name: 'myParam',
type: 'string',
modes: [{ displayName: 'From list', name: 'list', type: 'list' }],
});
it('keeps the expression selector for a contributed input that does not own expression rendering', () => {
parameterInputRegistry.register({ type: 'string', component: contributedInput });
const { getByTestId } = renderComponent({
props: { parameter: listOnlyParameter, displayOptions: true },
});
expect(getByTestId('radio-button-expression')).toBeInTheDocument();
});
it('drops the expression selector for a list-only parameter when the input owns expression rendering', () => {
parameterInputRegistry.register({
type: 'string',
component: contributedInput,
capabilities: { ownsExpressionRendering: true },
});
const { queryByTestId } = renderComponent({
props: { parameter: listOnlyParameter, displayOptions: true },
});
expect(queryByTestId('radio-button-expression')).not.toBeInTheDocument();
});
});
});
});
@@ -38,6 +38,7 @@ import { ChatHubToolContextKey, ExpressionLocalResolveContextSymbol } from '@/ap
import { N8nInputLabel } from '@n8n/design-system';
import { useCollectionOverhaul } from '@/app/composables/useCollectionOverhaul';
import { useParameterInputContribution } from '@/features/ndv/parameters/composables/useParameterInputContribution';
import type { ParameterOptionsOverrides } from '@/features/ndv/shared/ndv.utils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
@@ -109,9 +110,16 @@ const activeNode = computed(() => {
});
const fromAIOverride = ref<FromAIOverride | null>(makeOverrideValue(props, activeNode.value));
// A module-contributed input declares the same capabilities the built-in
// resource-locator family behaves with, so both go through one value here.
const { capabilities: inputCapabilities } = useParameterInputContribution(
computed(() => props.parameter.type),
);
const canCreateContentOverride = computed(() => {
// The resourceLocator handles overrides separately
if (props.disableFromAi || !activeNode.value || isResourceLocator.value) return false;
// An input that owns the from-AI override handles it separately
if (props.disableFromAi || !activeNode.value || inputCapabilities.value.ownsFromAiOverride)
return false;
return canBeContentOverride(props, activeNode.value);
});
@@ -134,7 +142,7 @@ const isDropDisabled = computed(
() =>
props.parameter.noDataExpression ||
props.isReadOnly ||
isResourceLocator.value ||
inputCapabilities.value.disableDrop ||
isExpression.value,
);
const isExpression = computed(() => isValueExpression(props.parameter, props.value));
@@ -151,8 +159,8 @@ const parameterTooltipText = computed(() =>
const showExpressionSelector = computed(() => {
if (props.optionsOverrides?.hideExpressionSelector) return false;
if (isResourceLocator.value) {
// The resourceLocator handles overrides itself, so we use this hack to
if (inputCapabilities.value.ownsExpressionRendering) {
// The input handles overrides itself, so we use this hack to
// infer whether it's overridden and we should hide the toggle
const value =
props.value && typeof props.value === 'object' && 'value' in props.value && props.value.value;
@@ -0,0 +1,103 @@
import { ref } from 'vue';
import type { NodePropertyTypes } from 'n8n-workflow';
import { parameterInputRegistry } from '@n8n/frontend-module-sdk';
import { useParameterInputContribution } from './useParameterInputContribution';
const stubComponent = { render: () => null };
describe('useParameterInputContribution', () => {
beforeEach(() => {
parameterInputRegistry.clear();
});
it('resolves no component for an unclaimed type', () => {
const { contributedComponent } = useParameterInputContribution(
ref<NodePropertyTypes>('string'),
);
expect(contributedComponent.value).toBeUndefined();
});
it('resolves the component a module registered', () => {
parameterInputRegistry.register({ type: 'string', component: stubComponent });
const { contributedComponent } = useParameterInputContribution(
ref<NodePropertyTypes>('string'),
);
expect(contributedComponent.value).toBe(stubComponent);
});
it('reuses one async wrapper per factory, so the input does not remount', () => {
const factory = async () => await Promise.resolve(stubComponent);
parameterInputRegistry.register({ type: 'string', component: factory });
const first = useParameterInputContribution(ref<NodePropertyTypes>('string'));
const second = useParameterInputContribution(ref<NodePropertyTypes>('string'));
expect(first.contributedComponent.value).toBe(second.contributedComponent.value);
});
it('follows the type ref', () => {
parameterInputRegistry.register({ type: 'string', component: stubComponent });
const type = ref<NodePropertyTypes>('number');
const { contributedComponent } = useParameterInputContribution(type);
expect(contributedComponent.value).toBeUndefined();
type.value = 'string';
expect(contributedComponent.value).toBe(stubComponent);
});
describe('capabilities', () => {
it('defaults every flag to false for an unclaimed non-resource-locator type', () => {
const { capabilities } = useParameterInputContribution(ref<NodePropertyTypes>('string'));
expect(capabilities.value).toEqual({
ownsExpressionRendering: false,
ownsFromAiOverride: false,
disableDrop: false,
});
});
it.each<NodePropertyTypes>(['resourceLocator', 'workflowSelector', 'agentSelector'])(
'reports the built-in capabilities for %s with no module registered',
(type) => {
const { capabilities } = useParameterInputContribution(ref(type));
expect(capabilities.value).toEqual({
ownsExpressionRendering: true,
ownsFromAiOverride: true,
disableDrop: true,
});
},
);
it('takes the flags a contribution declares', () => {
parameterInputRegistry.register({
type: 'string',
component: stubComponent,
capabilities: { disableDrop: true },
});
const { capabilities } = useParameterInputContribution(ref<NodePropertyTypes>('string'));
expect(capabilities.value).toEqual({
ownsExpressionRendering: false,
ownsFromAiOverride: false,
disableDrop: true,
});
});
it('keeps the built-in capabilities when a module claims a resource-locator type', () => {
parameterInputRegistry.register({ type: 'resourceLocator', component: stubComponent });
const { capabilities } = useParameterInputContribution(
ref<NodePropertyTypes>('resourceLocator'),
);
expect(capabilities.value.disableDrop).toBe(true);
});
});
});
@@ -0,0 +1,58 @@
import { computed, defineAsyncComponent, type Component, type Ref } from 'vue';
import type { NodePropertyTypes } from 'n8n-workflow';
import { parameterInputRegistry } from '@n8n/frontend-module-sdk';
import type { ParameterInputCapabilities } from '@n8n/frontend-module-sdk';
import { isResourceLocatorParameterType } from '@/features/ndv/shared/ndv.utils';
type ResolvedCapabilities = Required<ParameterInputCapabilities>;
/**
* `defineAsyncComponent` wrappers, kept per factory. A fresh wrapper on every
* computed re-evaluation would remount the input on unrelated state changes.
*/
const asyncComponentCache = new WeakMap<() => Promise<Component>, Component>();
function resolveComponent(component: Component | (() => Promise<Component>)): Component {
if (typeof component !== 'function') return component;
const factory = component as () => Promise<Component>;
let resolved = asyncComponentCache.get(factory);
if (!resolved) {
resolved = defineAsyncComponent(factory);
asyncComponentCache.set(factory, resolved);
}
return resolved;
}
/**
* Resolves a module-contributed input for `parameter.type`, and the capabilities the
* surrounding components must hand over to it.
*
* The built-in resource-locator family reports the same capabilities it behaves with
* today, so a type that no module claims keeps its current behaviour. That is
* also what keeps this correct while the family is moving into a module: the
* flags are keyed on the type, not on who renders it.
*/
export function useParameterInputContribution(type: Ref<NodePropertyTypes>) {
const contribution = computed(() => parameterInputRegistry.get(type.value));
const contributedComponent = computed(() =>
contribution.value ? resolveComponent(contribution.value.component) : undefined,
);
const capabilities = computed<ResolvedCapabilities>(() => {
const declared = contribution.value?.capabilities;
const isBuiltInResourceLocator = isResourceLocatorParameterType(type.value);
return {
ownsExpressionRendering:
isBuiltInResourceLocator || declared?.ownsExpressionRendering === true,
ownsFromAiOverride: isBuiltInResourceLocator || declared?.ownsFromAiOverride === true,
disableDrop: isBuiltInResourceLocator || declared?.disableDrop === true,
};
});
return { contribution, contributedComponent, capabilities };
}
+6
View File
@@ -5551,6 +5551,12 @@ importers:
'@n8n/design-system':
specifier: workspace:*
version: link:../design-system
'@n8n/utils':
specifier: workspace:*
version: link:../../../@n8n/utils
n8n-workflow:
specifier: workspace:*
version: link:../../../workflow
vue:
specifier: catalog:frontend
version: 3.5.26([email protected])