@n8n/frontend-module-sdk 0.1.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/LICENSE.md ADDED
@@ -0,0 +1,88 @@
1
+ # License
2
+
3
+ Portions of this software are licensed as follows:
4
+
5
+ - Content of branches other than the main branch (i.e. "master") are not licensed.
6
+ - Source code files that contain ".ee." in their filename or ".ee" in their dirname are NOT licensed under
7
+ the Sustainable Use License.
8
+ To use source code files that contain ".ee." in their filename or ".ee" in their dirname you must hold a
9
+ valid n8n Enterprise License specifically allowing you access to such source code files and as defined
10
+ in "LICENSE_EE.md".
11
+ - All third party components incorporated into the n8n Software are licensed under the original license
12
+ provided by the owner of the applicable component.
13
+ - Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
14
+ License" as defined below.
15
+
16
+ ## Sustainable Use License
17
+
18
+ Version 1.0
19
+
20
+ ### Acceptance
21
+
22
+ By using the software, you agree to all of the terms and conditions below.
23
+
24
+ ### Copyright License
25
+
26
+ The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
27
+ to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
28
+ to the limitations below.
29
+
30
+ ### Limitations
31
+
32
+ You may use or modify the software only for your own internal business purposes or for non-commercial or
33
+ personal use. You may distribute the software or provide it to others only if you do so free of charge for
34
+ non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
35
+ the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law.
36
+
37
+ ### Patents
38
+
39
+ The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
40
+ license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
41
+ subject to the limitations and conditions in this license. This license does not cover any patent claims that
42
+ you cause to be infringed by modifications or additions to the software. If you or your company make any
43
+ written claim that the software infringes or contributes to infringement of any patent, your patent license
44
+ for the software granted under these terms ends immediately. If your company makes such a claim, your patent
45
+ license ends immediately for work on behalf of your company.
46
+
47
+ ### Notices
48
+
49
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
50
+ terms. If you modify the software, you must include in any modified copies of the software a prominent notice
51
+ stating that you have modified the software.
52
+
53
+ ### No Other Rights
54
+
55
+ These terms do not imply any licenses other than those expressly granted in these terms.
56
+
57
+ ### Termination
58
+
59
+ If you use the software in violation of these terms, such use is not licensed, and your license will
60
+ automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
61
+ violation of this license no later than 30 days after you receive that notice, your license will be reinstated
62
+ retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
63
+ terms will cause your license to terminate automatically and permanently.
64
+
65
+ ### No Liability
66
+
67
+ As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
68
+ not be liable to you for any damages arising out of these terms or the use or nature of the software, under
69
+ any kind of legal claim.
70
+
71
+ ### Definitions
72
+
73
+ The “licensor” is the entity offering these terms.
74
+
75
+ The “software” is the software the licensor makes available under these terms, including any portion of it.
76
+
77
+ “You” refers to the individual or entity agreeing to these terms.
78
+
79
+ “Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
80
+ all organizations that have control over, are under the control of, or are under common control with that
81
+ organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
82
+ management and policies by vote, contract, or otherwise. Control can be direct or indirect.
83
+
84
+ “Your license” is the license granted to you for the software under these terms.
85
+
86
+ “Use” means anything you do with the software requiring your license.
87
+
88
+ “Trademark” means trademarks, service marks, and similar rights.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @n8n/frontend-module-sdk
2
+
3
+ The frontend module contract and registries for n8n editor modules.
4
+
5
+ This package owns the `FrontendModuleDescription` descriptor type plus the modal
6
+ and resource registries that features register against. The editor shell keeps
7
+ the wiring (`moduleInitializer`) that drives the two-phase lifecycle; this
8
+ package only defines the contract and the registry state.
9
+
10
+ It is a source-only package (no build step): consumers resolve it from `src`
11
+ via the editor-ui Vite alias and `tsconfig` paths, and `src/index.ts` is the
12
+ only public entry point.
13
+
14
+ ```ts
15
+ import { modalRegistry, registerResource, type FrontendModuleDescription } from '@n8n/frontend-module-sdk';
16
+ ```
package/biome.jsonc ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
3
+ "extends": ["../../../../biome.jsonc"]
4
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'eslint/config';
2
+ import { frontendConfig } from '@n8n/eslint-config/frontend';
3
+
4
+ export default defineConfig(frontendConfig, {
5
+ files: ['**/*.test.ts'],
6
+ rules: { '@typescript-eslint/no-unsafe-assignment': 'warn' },
7
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@n8n/frontend-module-sdk",
4
+ "version": "0.1.0",
5
+ "main": "src/index.ts",
6
+ "import": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "vue": "^3.5.13",
12
+ "vue-router": "^4.5.0",
13
+ "@n8n/design-system": "2.30.0"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "6.0.2",
17
+ "unplugin-icons": "^23.0.1",
18
+ "vite": "^8.0.2",
19
+ "vitest": "^4.1.9",
20
+ "vue-tsc": "^2.2.8",
21
+ "@n8n/typescript-config": "1.9.0",
22
+ "@n8n/eslint-config": "0.0.1",
23
+ "@n8n/vitest-config": "1.18.0"
24
+ },
25
+ "license": "LicenseRef-n8n-sustainable-use",
26
+ "scripts": {
27
+ "clean": "rimraf .turbo",
28
+ "typecheck": "vue-tsc --noEmit",
29
+ "test": "vitest run",
30
+ "test:dev": "vitest",
31
+ "lint": "eslint src --quiet",
32
+ "lint:fix": "eslint src --fix",
33
+ "format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
34
+ "format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
35
+ }
36
+ }
@@ -0,0 +1,4 @@
1
+ // Test setup entry required by the shared frontend vitest config
2
+ // (`@n8n/vitest-config/frontend` points `setupFiles` here). The registry suites
3
+ // need no global setup, so this file intentionally stays empty.
4
+ export {};
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type * from './module.types';
2
+ export * as modalRegistry from './modalRegistry';
3
+ export * from './resourceRegistry';
@@ -0,0 +1,299 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import type { Component } from 'vue';
3
+
4
+ import * as modalRegistry from './modalRegistry';
5
+ import type { ModalDefinition } from './module.types';
6
+
7
+ describe('modalRegistry', () => {
8
+ const mockComponent1 = { name: 'TestModal1' } as Component;
9
+ const mockComponent2 = { name: 'TestModal2' } as Component;
10
+ const mockAsyncComponent = (): Component => {
11
+ return { name: 'AsyncTestModal3' } as Component;
12
+ };
13
+
14
+ const mockModal1: ModalDefinition = {
15
+ key: 'test-modal-1',
16
+ component: mockComponent1,
17
+ initialState: { open: false },
18
+ };
19
+
20
+ const mockModal2: ModalDefinition = {
21
+ key: 'test-modal-2',
22
+ component: mockComponent2,
23
+ };
24
+
25
+ const mockModal3: ModalDefinition = {
26
+ key: 'test-modal-3',
27
+ component: mockAsyncComponent,
28
+ };
29
+
30
+ beforeEach(() => {
31
+ // Clear all modals before each test
32
+ const keys = modalRegistry.getKeys();
33
+ keys.forEach((key) => modalRegistry.unregister(key));
34
+ });
35
+
36
+ describe('register', () => {
37
+ it('should register a new modal', () => {
38
+ modalRegistry.register(mockModal1);
39
+
40
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
41
+ expect(modalRegistry.get('test-modal-1')).toEqual(mockModal1);
42
+ });
43
+
44
+ it('should register multiple modals', () => {
45
+ modalRegistry.register(mockModal1);
46
+ modalRegistry.register(mockModal2);
47
+
48
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
49
+ expect(modalRegistry.has('test-modal-2')).toBe(true);
50
+ expect(modalRegistry.getKeys()).toHaveLength(2);
51
+ });
52
+
53
+ it('should warn and skip registration if modal key already exists', () => {
54
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
55
+
56
+ modalRegistry.register(mockModal1);
57
+ modalRegistry.register(mockModal1);
58
+
59
+ expect(consoleSpy).toHaveBeenCalledWith(
60
+ 'Modal with key "test-modal-1" is already registered. Skipping.',
61
+ );
62
+ expect(modalRegistry.getKeys()).toHaveLength(1);
63
+
64
+ consoleSpy.mockRestore();
65
+ });
66
+
67
+ it('should notify listeners when a modal is registered', () => {
68
+ const listener = vi.fn();
69
+ modalRegistry.subscribe(listener);
70
+
71
+ modalRegistry.register(mockModal1);
72
+
73
+ expect(listener).toHaveBeenCalledWith(expect.any(Map));
74
+ expect(listener).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it('should register modal with async component', () => {
78
+ modalRegistry.register(mockModal3);
79
+
80
+ expect(modalRegistry.has('test-modal-3')).toBe(true);
81
+ expect(modalRegistry.get('test-modal-3')).toEqual(mockModal3);
82
+ });
83
+ });
84
+
85
+ describe('unregister', () => {
86
+ it('should unregister an existing modal', () => {
87
+ modalRegistry.register(mockModal1);
88
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
89
+
90
+ modalRegistry.unregister('test-modal-1');
91
+
92
+ expect(modalRegistry.has('test-modal-1')).toBe(false);
93
+ expect(modalRegistry.get('test-modal-1')).toBeUndefined();
94
+ });
95
+
96
+ it('should not notify listeners if modal does not exist', () => {
97
+ const listener = vi.fn();
98
+ modalRegistry.subscribe(listener);
99
+
100
+ modalRegistry.unregister('non-existent-modal');
101
+
102
+ expect(listener).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('should notify listeners when a modal is unregistered', () => {
106
+ modalRegistry.register(mockModal1);
107
+ const listener = vi.fn();
108
+ modalRegistry.subscribe(listener);
109
+
110
+ modalRegistry.unregister('test-modal-1');
111
+
112
+ expect(listener).toHaveBeenCalledWith(expect.any(Map));
113
+ expect(listener).toHaveBeenCalledTimes(1);
114
+ });
115
+ });
116
+
117
+ describe('get', () => {
118
+ it('should return modal definition for existing key', () => {
119
+ modalRegistry.register(mockModal1);
120
+
121
+ const result = modalRegistry.get('test-modal-1');
122
+
123
+ expect(result).toEqual(mockModal1);
124
+ });
125
+
126
+ it('should return undefined for non-existent key', () => {
127
+ const result = modalRegistry.get('non-existent-modal');
128
+
129
+ expect(result).toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe('getAll', () => {
134
+ it('should return empty map when no modals are registered', () => {
135
+ const result = modalRegistry.getAll();
136
+
137
+ expect(result).toBeInstanceOf(Map);
138
+ expect(result.size).toBe(0);
139
+ });
140
+
141
+ it('should return all registered modals', () => {
142
+ modalRegistry.register(mockModal1);
143
+ modalRegistry.register(mockModal2);
144
+
145
+ const result = modalRegistry.getAll();
146
+
147
+ expect(result).toBeInstanceOf(Map);
148
+ expect(result.size).toBe(2);
149
+ expect(result.get('test-modal-1')).toEqual(mockModal1);
150
+ expect(result.get('test-modal-2')).toEqual(mockModal2);
151
+ });
152
+
153
+ it('should return a copy of the internal map', () => {
154
+ modalRegistry.register(mockModal1);
155
+
156
+ const result1 = modalRegistry.getAll();
157
+ const result2 = modalRegistry.getAll();
158
+
159
+ expect(result1).not.toBe(result2);
160
+ expect(result1).toEqual(result2);
161
+ });
162
+ });
163
+
164
+ describe('getKeys', () => {
165
+ it('should return empty array when no modals are registered', () => {
166
+ const result = modalRegistry.getKeys();
167
+
168
+ expect(result).toEqual([]);
169
+ });
170
+
171
+ it('should return array of all modal keys', () => {
172
+ modalRegistry.register(mockModal1);
173
+ modalRegistry.register(mockModal2);
174
+
175
+ const result = modalRegistry.getKeys();
176
+
177
+ expect(result).toEqual(['test-modal-1', 'test-modal-2']);
178
+ });
179
+ });
180
+
181
+ describe('has', () => {
182
+ it('should return true for existing modal key', () => {
183
+ modalRegistry.register(mockModal1);
184
+
185
+ expect(modalRegistry.has('test-modal-1')).toBe(true);
186
+ });
187
+
188
+ it('should return false for non-existent modal key', () => {
189
+ expect(modalRegistry.has('non-existent-modal')).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe('subscribe', () => {
194
+ it('should call listener when modals are updated', () => {
195
+ const listener = vi.fn();
196
+ modalRegistry.subscribe(listener);
197
+
198
+ modalRegistry.register(mockModal1);
199
+
200
+ expect(listener).toHaveBeenCalledWith(expect.any(Map));
201
+ expect(listener).toHaveBeenCalledTimes(1);
202
+
203
+ const calledMap = listener.mock.calls[0]?.[0] as Map<string, ModalDefinition>;
204
+ expect(calledMap?.get('test-modal-1')).toEqual(mockModal1);
205
+ });
206
+
207
+ it('should return unsubscribe function', () => {
208
+ const listener = vi.fn();
209
+ const unsubscribe = modalRegistry.subscribe(listener);
210
+
211
+ expect(typeof unsubscribe).toBe('function');
212
+
213
+ modalRegistry.register(mockModal1);
214
+ expect(listener).toHaveBeenCalledTimes(1);
215
+
216
+ unsubscribe();
217
+ modalRegistry.register(mockModal2);
218
+ expect(listener).toHaveBeenCalledTimes(1);
219
+ });
220
+
221
+ it('should support multiple listeners', () => {
222
+ const listener1 = vi.fn();
223
+ const listener2 = vi.fn();
224
+
225
+ modalRegistry.subscribe(listener1);
226
+ modalRegistry.subscribe(listener2);
227
+
228
+ modalRegistry.register(mockModal1);
229
+
230
+ expect(listener1).toHaveBeenCalledTimes(1);
231
+ expect(listener2).toHaveBeenCalledTimes(1);
232
+ });
233
+
234
+ it('should call listeners with current state of modals', () => {
235
+ modalRegistry.register(mockModal1);
236
+ modalRegistry.register(mockModal2);
237
+
238
+ const listener = vi.fn();
239
+ modalRegistry.subscribe(listener);
240
+
241
+ modalRegistry.register(mockModal3);
242
+
243
+ const calledMap = listener.mock.calls[0]?.[0] as Map<string, ModalDefinition>;
244
+ expect(calledMap?.size).toBe(3);
245
+ expect(calledMap?.has('test-modal-1')).toBe(true);
246
+ expect(calledMap?.has('test-modal-2')).toBe(true);
247
+ expect(calledMap?.has('test-modal-3')).toBe(true);
248
+ });
249
+ });
250
+
251
+ describe('edge cases', () => {
252
+ it('should handle empty string key', () => {
253
+ const emptyKeyModal: ModalDefinition = {
254
+ key: '',
255
+ component: { name: 'EmptyKeyModal' } as Component,
256
+ };
257
+
258
+ modalRegistry.register(emptyKeyModal);
259
+
260
+ expect(modalRegistry.has('')).toBe(true);
261
+ expect(modalRegistry.get('')).toEqual(emptyKeyModal);
262
+ });
263
+
264
+ it('should handle special characters in key', () => {
265
+ const specialKeyModal: ModalDefinition = {
266
+ key: 'modal-with-$special_ch@rs!',
267
+ component: { name: 'SpecialModal' } as Component,
268
+ };
269
+
270
+ modalRegistry.register(specialKeyModal);
271
+
272
+ expect(modalRegistry.has('modal-with-$special_ch@rs!')).toBe(true);
273
+ expect(modalRegistry.get('modal-with-$special_ch@rs!')).toEqual(specialKeyModal);
274
+ });
275
+
276
+ it('should handle modal without initialState', () => {
277
+ const modalWithoutState: ModalDefinition = {
278
+ key: 'no-state-modal',
279
+ component: { name: 'NoStateModal' } as Component,
280
+ };
281
+
282
+ modalRegistry.register(modalWithoutState);
283
+
284
+ const retrieved = modalRegistry.get('no-state-modal');
285
+ expect(retrieved).toEqual(modalWithoutState);
286
+ expect(retrieved?.initialState).toBeUndefined();
287
+ });
288
+
289
+ it('should maintain registration order in getKeys', () => {
290
+ modalRegistry.register(mockModal2);
291
+ modalRegistry.register(mockModal1);
292
+ modalRegistry.register(mockModal3);
293
+
294
+ const keys = modalRegistry.getKeys();
295
+
296
+ expect(keys).toEqual(['test-modal-2', 'test-modal-1', 'test-modal-3']);
297
+ });
298
+ });
299
+ });
@@ -0,0 +1,47 @@
1
+ import type { ModalDefinition } from './module.types';
2
+
3
+ const modals = new Map<string, ModalDefinition>();
4
+ const listeners = new Set<(modals: Map<string, ModalDefinition>) => void>();
5
+
6
+ export function getAll(): Map<string, ModalDefinition> {
7
+ return new Map(modals);
8
+ }
9
+
10
+ function notifyListeners(): void {
11
+ listeners.forEach((listener) => listener(getAll()));
12
+ }
13
+
14
+ export function register(modal: ModalDefinition): void {
15
+ if (modals.has(modal.key)) {
16
+ console.warn(`Modal with key "${modal.key}" is already registered. Skipping.`);
17
+ return;
18
+ }
19
+
20
+ modals.set(modal.key, modal);
21
+ notifyListeners();
22
+ }
23
+
24
+ export function unregister(key: string): void {
25
+ if (modals.delete(key)) {
26
+ notifyListeners();
27
+ }
28
+ }
29
+
30
+ export function get(key: string): ModalDefinition | undefined {
31
+ return modals.get(key);
32
+ }
33
+
34
+ export function getKeys(): string[] {
35
+ return Array.from(modals.keys());
36
+ }
37
+
38
+ export function has(key: string): boolean {
39
+ return modals.has(key);
40
+ }
41
+
42
+ export function subscribe(listener: (modals: Map<string, ModalDefinition>) => void): () => void {
43
+ listeners.add(listener);
44
+ return () => {
45
+ listeners.delete(listener);
46
+ };
47
+ }
@@ -0,0 +1,52 @@
1
+ import type { IMenuItem, TabOptions } from '@n8n/design-system';
2
+ import type { Component } from 'vue';
3
+ import type { RouteRecordRaw } from 'vue-router';
4
+
5
+ export type ModalState = {
6
+ open: boolean;
7
+ mode?: string | null;
8
+ data?: Record<string, unknown>;
9
+ activeId?: string | null;
10
+ curlCommand?: string;
11
+ httpNodeParameters?: string;
12
+ };
13
+
14
+ export type DynamicTabOptions = TabOptions<string> & {
15
+ dynamicRoute?: {
16
+ name: string;
17
+ includeProjectId?: boolean;
18
+ };
19
+ /**
20
+ * Insert this tab immediately after the tab whose `value` matches.
21
+ * If unset (or no match is found at render time), the tab is appended at the end.
22
+ */
23
+ insertAfter?: string;
24
+ };
25
+
26
+ export type ModalDefinition = {
27
+ key: string;
28
+ component: Component | (() => Promise<Component>);
29
+ initialState?: ModalState;
30
+ };
31
+
32
+ export type ResourceMetadata = {
33
+ key: string;
34
+ displayName: string;
35
+ i18nKeys?: Record<string, string>;
36
+ };
37
+
38
+ export type FrontendModuleDescription = {
39
+ id: string;
40
+ name: string;
41
+ description: string;
42
+ icon: string;
43
+ routes?: RouteRecordRaw[];
44
+ projectTabs?: {
45
+ overview?: DynamicTabOptions[];
46
+ project?: DynamicTabOptions[];
47
+ shared?: DynamicTabOptions[];
48
+ };
49
+ resources?: ResourceMetadata[];
50
+ modals?: ModalDefinition[];
51
+ settingsPages?: IMenuItem[];
52
+ };
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ import type { ResourceMetadata } from './module.types';
4
+
5
+ // The registry keeps module-level state and exposes no reset, so re-import a
6
+ // fresh module instance before each test to keep them isolated.
7
+ const importRegistry = async () => await import('./resourceRegistry');
8
+
9
+ describe('resourceRegistry', () => {
10
+ let resourceRegistry: Awaited<ReturnType<typeof importRegistry>>;
11
+
12
+ const resourceA: ResourceMetadata = {
13
+ key: 'resource-a',
14
+ displayName: 'Resource A',
15
+ };
16
+
17
+ const resourceB: ResourceMetadata = {
18
+ key: 'resource-b',
19
+ displayName: 'Resource B',
20
+ i18nKeys: { title: 'resourceB.title' },
21
+ };
22
+
23
+ beforeEach(async () => {
24
+ vi.resetModules();
25
+ resourceRegistry = await importRegistry();
26
+ });
27
+
28
+ describe('registerResource', () => {
29
+ it('should register a new resource', () => {
30
+ resourceRegistry.registerResource(resourceA);
31
+
32
+ expect(resourceRegistry.hasResource('resource-a')).toBe(true);
33
+ expect(resourceRegistry.getResource('resource-a')).toEqual(resourceA);
34
+ });
35
+
36
+ it('should register multiple resources', () => {
37
+ resourceRegistry.registerResource(resourceA);
38
+ resourceRegistry.registerResource(resourceB);
39
+
40
+ expect(resourceRegistry.getAllResourceKeys()).toEqual(['resource-a', 'resource-b']);
41
+ });
42
+
43
+ it('should overwrite a resource registered with the same key', () => {
44
+ resourceRegistry.registerResource(resourceA);
45
+ const updated: ResourceMetadata = { key: 'resource-a', displayName: 'Updated A' };
46
+
47
+ resourceRegistry.registerResource(updated);
48
+
49
+ expect(resourceRegistry.getResource('resource-a')).toEqual(updated);
50
+ expect(resourceRegistry.getAllResourceKeys()).toHaveLength(1);
51
+ });
52
+ });
53
+
54
+ describe('getResource', () => {
55
+ it('should return resource metadata for an existing key', () => {
56
+ resourceRegistry.registerResource(resourceB);
57
+
58
+ expect(resourceRegistry.getResource('resource-b')).toEqual(resourceB);
59
+ });
60
+
61
+ it('should return undefined for a non-existent key', () => {
62
+ expect(resourceRegistry.getResource('non-existent-resource')).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe('hasResource', () => {
67
+ it('should return true for a registered key', () => {
68
+ resourceRegistry.registerResource(resourceA);
69
+
70
+ expect(resourceRegistry.hasResource('resource-a')).toBe(true);
71
+ });
72
+
73
+ it('should return false for an unregistered key', () => {
74
+ expect(resourceRegistry.hasResource('non-existent-resource')).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe('getAllResourceKeys', () => {
79
+ it('should return an empty array when no resources are registered', () => {
80
+ expect(resourceRegistry.getAllResourceKeys()).toEqual([]);
81
+ });
82
+
83
+ it('should return keys in registration order', () => {
84
+ resourceRegistry.registerResource(resourceB);
85
+ resourceRegistry.registerResource(resourceA);
86
+
87
+ expect(resourceRegistry.getAllResourceKeys()).toEqual(['resource-b', 'resource-a']);
88
+ });
89
+ });
90
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Allows features to register their resource types without modifying core components
3
+ * These resources can then be used in ResourcesListLayout.
4
+ *
5
+ * Apart from this, new resource types can be registered like this:
6
+ 1. Create a types.d.ts file in your feature directory
7
+ 2. Define your resource type extending BaseResource
8
+ 3. Use module augmentation to extend ModuleResources:
9
+ declare module '@/Interface' {
10
+ interface ModuleResources {
11
+ myFeature: MyFeatureResource;
12
+ }
13
+ }
14
+ 4. Import your resource type from the local types file in your components
15
+ */
16
+
17
+ import { type ResourceMetadata } from './module.types';
18
+
19
+ // Private module state
20
+ const resources: Map<string, ResourceMetadata> = new Map();
21
+
22
+ /**
23
+ * Register a new resource type
24
+ */
25
+ export function registerResource(metadata: ResourceMetadata): void {
26
+ resources.set(metadata.key, metadata);
27
+ }
28
+
29
+ /**
30
+ * Get resource metadata by key
31
+ */
32
+ export function getResource(key: string): ResourceMetadata | undefined {
33
+ return resources.get(key);
34
+ }
35
+
36
+ /**
37
+ * Check if a resource is registered
38
+ */
39
+ export function hasResource(key: string): boolean {
40
+ return resources.has(key);
41
+ }
42
+
43
+ /**
44
+ * Get all registered resource keys
45
+ */
46
+ export function getAllResourceKeys(): string[] {
47
+ return Array.from(resources.keys());
48
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "@n8n/typescript-config/tsconfig.frontend.json",
3
+ "compilerOptions": {
4
+ "rootDirs": [".", "../design-system/src", "../composables/src"],
5
+ "noEmit": true,
6
+ "moduleResolution": "bundler",
7
+ "types": [
8
+ "vite/client",
9
+ "vitest/globals",
10
+ "unplugin-icons/types/vue",
11
+ "../design-system/src/shims-modules.d.ts"
12
+ ],
13
+ "paths": {
14
+ "@n8n/design-system*": ["../design-system/src*"],
15
+ "@n8n/composables*": ["../composables/src*"],
16
+ "@n8n/utils*": ["../../../@n8n/utils/src*"]
17
+ }
18
+ },
19
+ "include": ["src/**/*.ts", "vite.config.ts"]
20
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { defineConfig, mergeConfig } from 'vite';
2
+ import { vitestConfig } from '@n8n/vitest-config/frontend';
3
+
4
+ export default mergeConfig(defineConfig({}), vitestConfig);