@n8n/frontend-module-sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,27 +1,29 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@n8n/frontend-module-sdk",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "main": "src/index.ts",
6
6
  "import": "src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts"
9
9
  },
10
10
  "dependencies": {
11
+ "@n8n/api-types": "1.39.0",
12
+ "@n8n/design-system": "2.38.0",
13
+ "@n8n/utils": "1.47.0",
14
+ "n8n-workflow": "2.39.0",
11
15
  "vue": "^3.5.13",
12
- "vue-router": "^4.5.0",
13
- "@n8n/api-types": "1.38.0",
14
- "@n8n/design-system": "2.37.0"
16
+ "vue-router": "^4.5.0"
15
17
  },
16
18
  "devDependencies": {
19
+ "@n8n/eslint-config": "0.0.1",
20
+ "@n8n/typescript-config": "1.11.0",
21
+ "@n8n/vitest-config": "1.21.0",
17
22
  "typescript": "6.0.2",
18
23
  "unplugin-icons": "^23.0.1",
19
24
  "vite": "^8.0.2",
20
25
  "vitest": "^4.1.9",
21
- "vue-tsc": "^2.2.8",
22
- "@n8n/eslint-config": "0.0.1",
23
- "@n8n/typescript-config": "1.11.0",
24
- "@n8n/vitest-config": "1.21.0"
26
+ "vue-tsc": "^2.2.8"
25
27
  },
26
28
  "license": "SEE LICENSE IN LICENSE.md",
27
29
  "homepage": "https://n8n.io",
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@ export * as modalRegistry from './registries/modalRegistry';
6
6
  export * from './registries/resourceRegistry';
7
7
  export * as pushHandlerRegistry from './registries/pushHandlerRegistry';
8
8
  export * as commandRegistry from './registries/commandRegistry';
9
+ export * as parameterInputRegistry from './registries/parameterInputRegistry';
@@ -0,0 +1,186 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { computed } from 'vue';
3
+ import type { Component } from 'vue';
4
+
5
+ import * as parameterInputRegistry from './parameterInputRegistry';
6
+ import type { ParameterInputContribution, ParameterInputType } from '../types/parameterInput';
7
+
8
+ describe('parameterInputRegistry', () => {
9
+ const resourceLocatorComponent = { name: 'TestResourceLocator' } as Component;
10
+ const workflowSelectorComponent = { name: 'TestWorkflowSelector' } as Component;
11
+ const asyncComponent = async (): Promise<Component> =>
12
+ await Promise.resolve({ name: 'AsyncTestInput' } as Component);
13
+
14
+ const resourceLocator: ParameterInputContribution = {
15
+ type: 'resourceLocator',
16
+ component: resourceLocatorComponent,
17
+ capabilities: { ownsExpressionRendering: true, ownsFromAiOverride: true, disableDrop: true },
18
+ };
19
+
20
+ const workflowSelector: ParameterInputContribution = {
21
+ type: 'workflowSelector',
22
+ component: workflowSelectorComponent,
23
+ };
24
+
25
+ const lazyInput: ParameterInputContribution = {
26
+ type: 'filter',
27
+ component: asyncComponent,
28
+ };
29
+
30
+ beforeEach(() => {
31
+ parameterInputRegistry.clear();
32
+ });
33
+
34
+ describe('register', () => {
35
+ it('should register a contribution under its parameter type', () => {
36
+ parameterInputRegistry.register(resourceLocator);
37
+
38
+ expect(parameterInputRegistry.has('resourceLocator')).toBe(true);
39
+ expect(parameterInputRegistry.get('resourceLocator')).toEqual(resourceLocator);
40
+ });
41
+
42
+ it('should register multiple contributions', () => {
43
+ parameterInputRegistry.register(resourceLocator);
44
+ parameterInputRegistry.register(workflowSelector);
45
+
46
+ expect(parameterInputRegistry.getAll().size).toBe(2);
47
+ });
48
+
49
+ it('should accept a lazy component factory', () => {
50
+ parameterInputRegistry.register(lazyInput);
51
+
52
+ expect(parameterInputRegistry.get('filter')?.component).toBe(asyncComponent);
53
+ });
54
+
55
+ it('should warn and skip when a different contribution claims a taken type', () => {
56
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
57
+
58
+ parameterInputRegistry.register(resourceLocator);
59
+ parameterInputRegistry.register({
60
+ type: 'resourceLocator',
61
+ component: workflowSelectorComponent,
62
+ });
63
+
64
+ expect(consoleSpy).toHaveBeenCalledWith(
65
+ 'Parameter input for type "resourceLocator" is already registered. Skipping.',
66
+ );
67
+ expect(parameterInputRegistry.get('resourceLocator')?.component).toBe(
68
+ resourceLocatorComponent,
69
+ );
70
+
71
+ consoleSpy.mockRestore();
72
+ });
73
+
74
+ it('should treat a replay of the same contribution as a no-op', () => {
75
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
76
+
77
+ parameterInputRegistry.register(resourceLocator);
78
+ parameterInputRegistry.register(resourceLocator);
79
+
80
+ expect(consoleSpy).not.toHaveBeenCalled();
81
+ expect(parameterInputRegistry.getAll().size).toBe(1);
82
+
83
+ consoleSpy.mockRestore();
84
+ });
85
+ });
86
+
87
+ describe('get', () => {
88
+ it('should return undefined for an unregistered type', () => {
89
+ parameterInputRegistry.register(resourceLocator);
90
+
91
+ expect(parameterInputRegistry.get('string')).toBeUndefined();
92
+ expect(parameterInputRegistry.has('string')).toBe(false);
93
+ });
94
+
95
+ it('should return undefined once every contribution is cleared', () => {
96
+ parameterInputRegistry.register(resourceLocator);
97
+ parameterInputRegistry.clear();
98
+
99
+ expect(parameterInputRegistry.get('resourceLocator')).toBeUndefined();
100
+ });
101
+ });
102
+
103
+ describe('unregister', () => {
104
+ it('should remove a registered type', () => {
105
+ parameterInputRegistry.register(resourceLocator);
106
+ parameterInputRegistry.unregister('resourceLocator');
107
+
108
+ expect(parameterInputRegistry.has('resourceLocator')).toBe(false);
109
+ });
110
+
111
+ it('should let the type be claimed again after removal', () => {
112
+ parameterInputRegistry.register(resourceLocator);
113
+ parameterInputRegistry.unregister('resourceLocator');
114
+ parameterInputRegistry.register({
115
+ type: 'resourceLocator',
116
+ component: workflowSelectorComponent,
117
+ });
118
+
119
+ expect(parameterInputRegistry.get('resourceLocator')?.component).toBe(
120
+ workflowSelectorComponent,
121
+ );
122
+ });
123
+ });
124
+
125
+ describe('getAll', () => {
126
+ it('should return a copy, so a caller cannot mutate the registry', () => {
127
+ parameterInputRegistry.register(resourceLocator);
128
+
129
+ parameterInputRegistry.getAll().delete('resourceLocator');
130
+
131
+ expect(parameterInputRegistry.has('resourceLocator')).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe('reactivity', () => {
136
+ it('should re-evaluate a computed when a contribution is registered', () => {
137
+ const resolved = computed(() => parameterInputRegistry.get('resourceLocator'));
138
+
139
+ expect(resolved.value).toBeUndefined();
140
+
141
+ parameterInputRegistry.register(resourceLocator);
142
+
143
+ expect(resolved.value).toEqual(resourceLocator);
144
+ });
145
+
146
+ it('should not wrap the component in a reactive proxy', () => {
147
+ parameterInputRegistry.register(resourceLocator);
148
+
149
+ // Shallow registry: a reactive component logs "Vue received a Component
150
+ // that was made a reactive object" and breaks `<component :is>`.
151
+ expect(parameterInputRegistry.get('resourceLocator')?.component).toBe(
152
+ resourceLocatorComponent,
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('subscribe', () => {
158
+ it('should notify listeners on register and unregister', () => {
159
+ const listener =
160
+ vi.fn<(entries: Map<ParameterInputType, ParameterInputContribution>) => void>();
161
+ const unsubscribe = parameterInputRegistry.subscribe(listener);
162
+
163
+ parameterInputRegistry.register(resourceLocator);
164
+ expect(listener).toHaveBeenCalledTimes(1);
165
+ expect(listener.mock.calls[0]?.[0].get('resourceLocator')).toEqual(resourceLocator);
166
+
167
+ parameterInputRegistry.unregister('resourceLocator');
168
+ expect(listener).toHaveBeenCalledTimes(2);
169
+
170
+ unsubscribe();
171
+ parameterInputRegistry.register(workflowSelector);
172
+ expect(listener).toHaveBeenCalledTimes(2);
173
+ });
174
+
175
+ it('should not notify when unregistering a type that was never claimed', () => {
176
+ const listener = vi.fn();
177
+ const unsubscribe = parameterInputRegistry.subscribe(listener);
178
+
179
+ parameterInputRegistry.unregister('string');
180
+
181
+ expect(listener).not.toHaveBeenCalled();
182
+
183
+ unsubscribe();
184
+ });
185
+ });
186
+ });
@@ -0,0 +1,72 @@
1
+ import { shallowReactive } from 'vue';
2
+
3
+ import type { ParameterInputContribution, ParameterInputType } from '../types/parameterInput';
4
+
5
+ /**
6
+ * Shallow-reactive so the render path can derive from the registry with a plain
7
+ * `computed`. Shallow on purpose: a contribution's `component` must not be
8
+ * turned into a reactive object.
9
+ */
10
+ const parameterInputs = shallowReactive(new Map<ParameterInputType, ParameterInputContribution>());
11
+ const listeners = new Set<(entries: Map<ParameterInputType, ParameterInputContribution>) => void>();
12
+
13
+ export function getAll(): Map<ParameterInputType, ParameterInputContribution> {
14
+ return new Map(parameterInputs);
15
+ }
16
+
17
+ function notifyListeners(): void {
18
+ listeners.forEach((listener) => listener(getAll()));
19
+ }
20
+
21
+ /**
22
+ * Claim `contribution.type` for this component. One owner per type: the shell's
23
+ * built-in branch for that type is what an unclaimed type falls back to.
24
+ */
25
+ export function register(contribution: ParameterInputContribution): void {
26
+ const existing = parameterInputs.get(contribution.type);
27
+ if (existing) {
28
+ // Replaying the same contribution is how a re-login re-runs registration —
29
+ // a no-op, not a collision. Only a different contribution claiming a taken
30
+ // type is worth warning about.
31
+ if (existing !== contribution) {
32
+ console.warn(
33
+ `Parameter input for type "${contribution.type}" is already registered. Skipping.`,
34
+ );
35
+ }
36
+ return;
37
+ }
38
+
39
+ parameterInputs.set(contribution.type, contribution);
40
+ notifyListeners();
41
+ }
42
+
43
+ export function unregister(type: ParameterInputType): void {
44
+ if (parameterInputs.delete(type)) {
45
+ notifyListeners();
46
+ }
47
+ }
48
+
49
+ export function get(type: ParameterInputType): ParameterInputContribution | undefined {
50
+ return parameterInputs.get(type);
51
+ }
52
+
53
+ export function has(type: ParameterInputType): boolean {
54
+ return parameterInputs.has(type);
55
+ }
56
+
57
+ export function subscribe(
58
+ listener: (entries: Map<ParameterInputType, ParameterInputContribution>) => void,
59
+ ): () => void {
60
+ listeners.add(listener);
61
+ return () => {
62
+ listeners.delete(listener);
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Remove all registered parameter inputs. Primarily for test isolation.
68
+ */
69
+ export function clear(): void {
70
+ parameterInputs.clear();
71
+ notifyListeners();
72
+ }
@@ -43,4 +43,23 @@ describe('FrontendModuleDescription', () => {
43
43
  expect(descriptor.commands?.[0]?.id).toBe('v2.open');
44
44
  expect(descriptor.pushHandlers?.workflowActivated).toBeTypeOf('function');
45
45
  });
46
+
47
+ it('accepts a parameter-input contribution with a lazy component', () => {
48
+ const descriptor: FrontendModuleDescription = {
49
+ id: 'inputs',
50
+ name: 'Inputs',
51
+ description: 'A descriptor contributing a parameter input',
52
+ icon: 'box',
53
+ parameterInputs: [
54
+ {
55
+ type: 'resourceLocator',
56
+ component: async () => await Promise.resolve({ name: 'ResourceLocator' }),
57
+ capabilities: { ownsExpressionRendering: true, disableDrop: true },
58
+ },
59
+ ],
60
+ };
61
+
62
+ expect(descriptor.parameterInputs?.[0]?.type).toBe('resourceLocator');
63
+ expect(descriptor.parameterInputs?.[0]?.capabilities?.ownsFromAiOverride).toBeUndefined();
64
+ });
46
65
  });
@@ -5,6 +5,7 @@ import type { ModuleBanner } from './banner';
5
5
  import type { CommandBarEntry } from './command';
6
6
  import type { ModuleLocaleMessages } from './locale';
7
7
  import type { ModalDefinition } from './modal';
8
+ import type { ParameterInputContribution } from './parameterInput';
8
9
  import type { ModulePushHandlers } from './push';
9
10
  import type { ResourceMetadata } from './resource';
10
11
  import type { ModuleSetupContext } from './setup';
@@ -48,6 +49,12 @@ export type FrontendModuleDescription = {
48
49
  commands?: CommandBarEntry[];
49
50
  /** Global keyboard shortcuts. */
50
51
  shortcuts?: ModuleShortcut[];
52
+ /**
53
+ * Custom parameter input components, resolved by `parameter.type` in the NDV
54
+ * parameter render path. One owner per type; an unclaimed type keeps the
55
+ * shell's built-in branch.
56
+ */
57
+ parameterInputs?: ParameterInputContribution[];
51
58
  /** Banners the module can contribute to the banner stack. */
52
59
  banners?: ModuleBanner[];
53
60
  /** Runs post-login, after the module is confirmed active. */
@@ -1,5 +1,6 @@
1
1
  export type * from './descriptor';
2
2
  export type * from './modal';
3
+ export type * from './parameterInput';
3
4
  export type * from './resource';
4
5
  export type * from './tabs';
5
6
  export type * from './push';
@@ -0,0 +1,99 @@
1
+ import type { EventBus } from '@n8n/utils/event-bus';
2
+ import type {
3
+ INode,
4
+ INodeProperties,
5
+ NodeParameterValueType,
6
+ NodePropertyTypes,
7
+ } from 'n8n-workflow';
8
+ import type { Component } from 'vue';
9
+
10
+ /**
11
+ * The `parameter.type` value a module claims. Deliberately the closed
12
+ * `NodePropertyTypes` union: a key the frontend accepts but a node author cannot
13
+ * declare would be a false surface. Widening the union later is additive.
14
+ */
15
+ export type ParameterInputType = NodePropertyTypes;
16
+
17
+ /**
18
+ * Props the shell passes to every contributed parameter input. The names and
19
+ * types match what `ParameterInput.vue` already passes to its built-in
20
+ * resource-locator branch, so an extracted component needs no adapter.
21
+ */
22
+ export type ParameterInputProps = {
23
+ parameter: INodeProperties;
24
+ modelValue: NodeParameterValueType;
25
+ path: string;
26
+ node?: INode;
27
+ displayTitle: string;
28
+ isReadOnly: boolean;
29
+ isValueExpression: boolean;
30
+ expressionDisplayValue: string;
31
+ expressionComputedValue: unknown;
32
+ /** `undefined` until the async resolution of `loadOptionsDependsOn` settles. */
33
+ dependentParametersValues?: string | null;
34
+ /**
35
+ * The parameter's validation issues, for an input that wants to place them
36
+ * itself. The shell also draws `ParameterIssues` below the input for every
37
+ * type outside the resource-locator family, so an input that renders these
38
+ * shows them twice.
39
+ */
40
+ parameterIssues: string[];
41
+ droppable: boolean;
42
+ eventBus?: EventBus;
43
+ };
44
+
45
+ /** Events the shell listens for on a contributed parameter input. */
46
+ export type ParameterInputEmits = {
47
+ // Vue fixes the v-model event name; it cannot be camelCase.
48
+ // eslint-disable-next-line @typescript-eslint/naming-convention
49
+ 'update:modelValue': [value: NodeParameterValueType];
50
+ modalOpenerClick: [];
51
+ focus: [];
52
+ blur: [];
53
+ drop: [value: string];
54
+ };
55
+
56
+ /**
57
+ * Capabilities the input takes over from `ParameterInputFull` / `ParameterInputWrapper`.
58
+ *
59
+ * These exist because `parameter.type` drives behaviour outside the render
60
+ * branch too — the expression toggle, the drop target and the from-AI override.
61
+ * An input that wins only the render branch would render correctly and still get
62
+ * the wrong capabilities. Every default keeps today's behaviour, so an input that
63
+ * needs none of it declares nothing.
64
+ */
65
+ export type ParameterInputCapabilities = {
66
+ /**
67
+ * The input draws its own expression editor, so the shell does not swap in
68
+ * `ExpressionParameterInput` and does not show the expression selector for a
69
+ * parameter whose modes are list-only.
70
+ */
71
+ ownsExpressionRendering?: boolean;
72
+ /** The input owns the from-AI override, so the shell hides its own toggle. */
73
+ ownsFromAiOverride?: boolean;
74
+ /** The shell refuses drag-and-drop onto the field. */
75
+ disableDrop?: boolean;
76
+ };
77
+
78
+ /**
79
+ * A component the shell can drive with `ParameterInputProps`. Typing the
80
+ * contribution against it makes the prop contract a compiler check rather than
81
+ * a comment: a component declaring an incompatible prop is rejected at the
82
+ * registry boundary.
83
+ */
84
+ export type ParameterInputComponent = Component<ParameterInputProps>;
85
+
86
+ export type ParameterInputContribution = {
87
+ /** The `parameter.type` this entry renders. Also the registry key. */
88
+ type: ParameterInputType;
89
+ /**
90
+ * Lazy on purpose: a `() => import()` keeps the module's `*.module.ts`
91
+ * import-light (design §5.2) and keeps the component out of the shell chunk.
92
+ *
93
+ * A bare function here is read as the loader, following `ModalDefinition`. So a
94
+ * functional component must be wrapped (`defineComponent(fn)`) or it is called
95
+ * as a loader and never renders.
96
+ */
97
+ component: ParameterInputComponent | (() => Promise<ParameterInputComponent>);
98
+ capabilities?: ParameterInputCapabilities;
99
+ };