@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 +88 -0
- package/README.md +16 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +7 -0
- package/package.json +36 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/index.ts +3 -0
- package/src/modalRegistry.test.ts +299 -0
- package/src/modalRegistry.ts +47 -0
- package/src/module.types.ts +52 -0
- package/src/resourceRegistry.test.ts +90 -0
- package/src/resourceRegistry.ts +48 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +4 -0
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
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -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