@n8n/frontend-module-mcp 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 +51 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -0
- package/src/SettingsMCPAgentsView.test.ts +256 -0
- package/src/SettingsMCPAgentsView.vue +223 -0
- package/src/SettingsMCPClientsView.test.ts +280 -0
- package/src/SettingsMCPClientsView.vue +168 -0
- package/src/SettingsMCPView.test.ts +832 -0
- package/src/SettingsMCPView.vue +496 -0
- package/src/SettingsMCPWorkflowsView.test.ts +274 -0
- package/src/SettingsMCPWorkflowsView.vue +245 -0
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/assets/client-icons/claude.svg +1 -0
- package/src/assets/client-icons/codex.svg +1 -0
- package/src/assets/client-icons/cursor.svg +1 -0
- package/src/assets/client-icons/gemini.svg +1 -0
- package/src/assets/client-icons/mistral.svg +1 -0
- package/src/assets/client-icons/openai.svg +3 -0
- package/src/assets/client-icons/vscode.svg +1 -0
- package/src/clients.utils.test.ts +85 -0
- package/src/clients.utils.ts +124 -0
- package/src/components/ConnectionParameter.vue +213 -0
- package/src/components/MCPAgentsSelect.vue +176 -0
- package/src/components/MCPEmptyState.vue +75 -0
- package/src/components/MCPWorkflowsSelect.test.ts +384 -0
- package/src/components/MCPWorkflowsSelect.vue +159 -0
- package/src/components/McpAccessToggle.vue +77 -0
- package/src/components/McpAccessTokenTab.test.ts +220 -0
- package/src/components/McpAccessTokenTab.vue +201 -0
- package/src/components/McpAllowedCallbackUrlsDialog.test.ts +122 -0
- package/src/components/McpAllowedCallbackUrlsDialog.vue +359 -0
- package/src/components/McpClientLogoCards.vue +134 -0
- package/src/components/McpConfigSnippet.vue +92 -0
- package/src/components/McpConnectClientDialog.test.ts +115 -0
- package/src/components/McpConnectClientDialog.vue +432 -0
- package/src/components/McpConnectedClientRow.test.ts +86 -0
- package/src/components/McpConnectedClientRow.vue +152 -0
- package/src/components/McpEmptyStateCard.vue +83 -0
- package/src/components/McpStatusControl.test.ts +33 -0
- package/src/components/McpStatusControl.vue +96 -0
- package/src/components/OAuthClientDetailsModal.vue +182 -0
- package/src/components/RevokeOAuthClientConfirmModal.vue +60 -0
- package/src/components/WorkflowLocation.test.ts +329 -0
- package/src/components/WorkflowLocation.vue +198 -0
- package/src/components/tabs/AgentsTable.vue +312 -0
- package/src/components/tabs/OAuthClientOwnerCell.vue +71 -0
- package/src/components/tabs/OAuthClientsFilters.test.ts +86 -0
- package/src/components/tabs/OAuthClientsFilters.vue +201 -0
- package/src/components/tabs/OAuthClientsTable.test.ts +394 -0
- package/src/components/tabs/OAuthClientsTable.vue +473 -0
- package/src/components/tabs/WorkflowsTable.test.ts +661 -0
- package/src/components/tabs/WorkflowsTable.vue +398 -0
- package/src/composables/useMcp.test.ts +59 -0
- package/src/composables/useMcp.ts +61 -0
- package/src/composables/useOAuthClientRevoke.ts +90 -0
- package/src/index.ts +10 -0
- package/src/mcp.api.test.ts +81 -0
- package/src/mcp.api.ts +206 -0
- package/src/mcp.clients.catalog.test.ts +63 -0
- package/src/mcp.clients.catalog.ts +183 -0
- package/src/mcp.constants.ts +45 -0
- package/src/mcp.module.test.ts +106 -0
- package/src/mcp.module.ts +90 -0
- package/src/mcp.store.test.ts +628 -0
- package/src/mcp.store.ts +490 -0
- package/src/mcp.test.utils.ts +57 -0
- package/src/mcp.types.ts +20 -0
- package/src/modals/MCPConnectAgentsModal.test.ts +141 -0
- package/src/modals/MCPConnectAgentsModal.vue +122 -0
- package/src/modals/MCPConnectWorkflowsModal.test.ts +303 -0
- package/src/modals/MCPConnectWorkflowsModal.vue +101 -0
- package/src/redirect-uris.utils.ts +22 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { waitFor, within } from '@testing-library/vue';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { createComponentRenderer, getTooltip } from '@n8n/frontend-test-utils';
|
|
4
|
+
import WorkflowsTable from './WorkflowsTable.vue';
|
|
5
|
+
import { createWorkflow } from '../../mcp.test.utils';
|
|
6
|
+
|
|
7
|
+
vi.mock('vue-router', async (importOriginal) => ({
|
|
8
|
+
...(await importOriginal()),
|
|
9
|
+
useRouter: () => ({
|
|
10
|
+
resolve: vi.fn(({ name, params }) => ({
|
|
11
|
+
fullPath:
|
|
12
|
+
name === 'NodeViewExisting'
|
|
13
|
+
? `/workflows/${params.workflowId}`
|
|
14
|
+
: `/projects/${params.projectId}`,
|
|
15
|
+
})),
|
|
16
|
+
}),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const createComponent = createComponentRenderer(WorkflowsTable);
|
|
20
|
+
|
|
21
|
+
describe('WorkflowsTable', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('Empty state', () => {
|
|
27
|
+
it('should render empty state when workflows array is empty', () => {
|
|
28
|
+
const { getByTestId } = createComponent({
|
|
29
|
+
props: {
|
|
30
|
+
workflows: [],
|
|
31
|
+
loading: false,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(getByTestId('mcp-workflow-table-empty-state')).toBeVisible();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should emit connectWorkflows event when button is clicked', async () => {
|
|
39
|
+
const { getByTestId, emitted } = createComponent({
|
|
40
|
+
props: {
|
|
41
|
+
workflows: [],
|
|
42
|
+
loading: false,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const button = getByTestId('mcp-workflow-table-empty-state-button');
|
|
47
|
+
await userEvent.click(button);
|
|
48
|
+
|
|
49
|
+
expect(emitted('connectWorkflows')).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('Loading state', () => {
|
|
54
|
+
it('should render loading skeleton when loading is true', () => {
|
|
55
|
+
const { container, queryByTestId } = createComponent({
|
|
56
|
+
props: {
|
|
57
|
+
workflows: [],
|
|
58
|
+
loading: true,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
|
|
63
|
+
expect(queryByTestId('mcp-workflow-table')).not.toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('Pagination', () => {
|
|
68
|
+
const createWorkflows = (count: number) =>
|
|
69
|
+
Array.from({ length: count }, (_, index) =>
|
|
70
|
+
createWorkflow({ id: `workflow-${index + 1}`, name: `Workflow ${index + 1}` }),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
it('should render pagination when total count exceeds page size', () => {
|
|
74
|
+
const { getByTestId } = createComponent({
|
|
75
|
+
props: {
|
|
76
|
+
workflows: createWorkflows(10),
|
|
77
|
+
totalCount: 11,
|
|
78
|
+
loading: false,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(getByTestId('pagination')).toBeVisible();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should emit table options when page changes', async () => {
|
|
86
|
+
const { getByTestId, emitted } = createComponent({
|
|
87
|
+
props: {
|
|
88
|
+
workflows: createWorkflows(10),
|
|
89
|
+
totalCount: 20,
|
|
90
|
+
loading: false,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await userEvent.click(within(getByTestId('pagination')).getByText('2'));
|
|
95
|
+
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
expect(emitted('update:options')).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
expect(emitted('update:options').at(-1)).toEqual([
|
|
100
|
+
expect.objectContaining({ page: 1, itemsPerPage: 10 }),
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('Bulk selection', () => {
|
|
106
|
+
const selectableWorkflows = () => [
|
|
107
|
+
createWorkflow({ id: 'wf-1', name: 'Workflow 1' }),
|
|
108
|
+
createWorkflow({ id: 'wf-2', name: 'Workflow 2' }),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const getRowCheckboxes = (container: Element) =>
|
|
112
|
+
container.querySelectorAll<HTMLElement>('tbody [role="checkbox"]');
|
|
113
|
+
|
|
114
|
+
it('should emit bulkRemoveMcpAccess with the selected workflow ids', async () => {
|
|
115
|
+
const { container, getByTestId, emitted } = createComponent({
|
|
116
|
+
props: { workflows: selectableWorkflows(), totalCount: 2, loading: false },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const rowCheckboxes = getRowCheckboxes(container);
|
|
120
|
+
expect(rowCheckboxes.length).toBe(2);
|
|
121
|
+
|
|
122
|
+
await userEvent.click(rowCheckboxes[0]);
|
|
123
|
+
|
|
124
|
+
expect(getByTestId('selected-items-info')).toBeVisible();
|
|
125
|
+
|
|
126
|
+
await userEvent.click(getByTestId('mcp-bulk-remove-access-button'));
|
|
127
|
+
|
|
128
|
+
expect(emitted('bulkRemoveMcpAccess')).toEqual([[['wf-1']]]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should select every selectable row via the header checkbox', async () => {
|
|
132
|
+
const { container, getByTestId, emitted } = createComponent({
|
|
133
|
+
props: { workflows: selectableWorkflows(), totalCount: 2, loading: false },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const headerCheckbox = container.querySelector<HTMLElement>('thead [role="checkbox"]');
|
|
137
|
+
expect(headerCheckbox).not.toBeNull();
|
|
138
|
+
await userEvent.click(headerCheckbox!);
|
|
139
|
+
|
|
140
|
+
await userEvent.click(getByTestId('mcp-bulk-remove-access-button'));
|
|
141
|
+
|
|
142
|
+
expect(emitted('bulkRemoveMcpAccess')).toEqual([[['wf-1', 'wf-2']]]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should disable selection for workflows the user cannot update', () => {
|
|
146
|
+
const { container } = createComponent({
|
|
147
|
+
props: {
|
|
148
|
+
workflows: [
|
|
149
|
+
createWorkflow({ id: 'wf-1' }),
|
|
150
|
+
createWorkflow({ id: 'wf-2', scopes: ['workflow:read'] }),
|
|
151
|
+
],
|
|
152
|
+
totalCount: 2,
|
|
153
|
+
loading: false,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const rowCheckboxes = getRowCheckboxes(container);
|
|
158
|
+
expect(rowCheckboxes[0]).not.toBeDisabled();
|
|
159
|
+
expect(rowCheckboxes[1]).toBeDisabled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should clear the selection from the selection bar', async () => {
|
|
163
|
+
const { container, getByTestId, queryByTestId } = createComponent({
|
|
164
|
+
props: { workflows: selectableWorkflows(), totalCount: 2, loading: false },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const rowCheckbox = getRowCheckboxes(container)[0];
|
|
168
|
+
await userEvent.click(rowCheckbox);
|
|
169
|
+
expect(getByTestId('selected-items-info')).toBeVisible();
|
|
170
|
+
expect(rowCheckbox).toHaveAttribute('aria-checked', 'true');
|
|
171
|
+
|
|
172
|
+
await userEvent.click(getByTestId('clear-selection-button'));
|
|
173
|
+
|
|
174
|
+
expect(queryByTestId('selected-items-info')).not.toBeInTheDocument();
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(rowCheckbox).toHaveAttribute('aria-checked', 'false');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should reset the selection when the workflows list reloads', async () => {
|
|
181
|
+
const { container, getByTestId, queryByTestId, rerender } = createComponent({
|
|
182
|
+
props: { workflows: selectableWorkflows(), totalCount: 2, loading: false },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await userEvent.click(getRowCheckboxes(container)[0]);
|
|
186
|
+
expect(getByTestId('selected-items-info')).toBeVisible();
|
|
187
|
+
|
|
188
|
+
await rerender({
|
|
189
|
+
workflows: [createWorkflow({ id: 'wf-3', name: 'Workflow 3' })],
|
|
190
|
+
totalCount: 1,
|
|
191
|
+
loading: false,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await waitFor(() => {
|
|
195
|
+
expect(queryByTestId('selected-items-info')).not.toBeInTheDocument();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('Workflow in project root', () => {
|
|
201
|
+
it('should render workflow with project location only (no folder)', () => {
|
|
202
|
+
const workflow = createWorkflow({
|
|
203
|
+
name: 'Workflow In Project Root',
|
|
204
|
+
homeProject: {
|
|
205
|
+
id: 'project1',
|
|
206
|
+
type: 'team',
|
|
207
|
+
name: 'My Project',
|
|
208
|
+
icon: { type: 'icon', value: 'folder' },
|
|
209
|
+
createdAt: '2025-01-01',
|
|
210
|
+
updatedAt: '2025-01-01',
|
|
211
|
+
},
|
|
212
|
+
parentFolder: undefined,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const { getByTestId, queryByTestId } = createComponent({
|
|
216
|
+
props: {
|
|
217
|
+
workflows: [workflow],
|
|
218
|
+
loading: false,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(getByTestId('mcp-workflow-name')).toHaveTextContent('Workflow In Project Root');
|
|
223
|
+
expect(getByTestId('workflow-location-project-name')).toHaveTextContent('My Project');
|
|
224
|
+
expect(queryByTestId('workflow-location-folder-name')).not.toBeInTheDocument();
|
|
225
|
+
expect(queryByTestId('workflow-location-ellipsis-separator')).not.toBeInTheDocument();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should render personal project name correctly', () => {
|
|
229
|
+
const workflow = createWorkflow({
|
|
230
|
+
homeProject: {
|
|
231
|
+
id: 'personal-project',
|
|
232
|
+
type: 'personal',
|
|
233
|
+
name: 'User<user@n8n.io>',
|
|
234
|
+
icon: { type: 'icon', value: 'user' },
|
|
235
|
+
createdAt: '2025-01-01',
|
|
236
|
+
updatedAt: '2025-01-01',
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const { getByTestId } = createComponent({
|
|
241
|
+
props: {
|
|
242
|
+
workflows: [workflow],
|
|
243
|
+
loading: false,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(getByTestId('workflow-location-project-name')).toHaveTextContent('Personal');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('Workflow in folder', () => {
|
|
252
|
+
it('should render workflow in folder without ellipsis (folder in project root)', () => {
|
|
253
|
+
const workflow = createWorkflow({
|
|
254
|
+
name: 'Workflow In Folder',
|
|
255
|
+
homeProject: {
|
|
256
|
+
id: 'project1',
|
|
257
|
+
type: 'team',
|
|
258
|
+
name: 'My Project',
|
|
259
|
+
icon: { type: 'icon', value: 'folder' },
|
|
260
|
+
createdAt: '2025-01-01',
|
|
261
|
+
updatedAt: '2025-01-01',
|
|
262
|
+
},
|
|
263
|
+
parentFolder: {
|
|
264
|
+
id: 'folder1',
|
|
265
|
+
name: 'Parent Folder',
|
|
266
|
+
parentFolderId: null,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const { getByTestId, queryByTestId } = createComponent({
|
|
271
|
+
props: {
|
|
272
|
+
workflows: [workflow],
|
|
273
|
+
loading: false,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(getByTestId('workflow-location-project-name')).toHaveTextContent('My Project');
|
|
278
|
+
expect(getByTestId('workflow-location-folder-name')).toHaveTextContent('Parent Folder');
|
|
279
|
+
// Separator between project and folder should be visible
|
|
280
|
+
expect(getByTestId('workflow-location-separator')).toBeVisible();
|
|
281
|
+
expect(queryByTestId('workflow-location-grandparent')).not.toBeInTheDocument();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should render workflow in nested folder with ellipsis', () => {
|
|
285
|
+
const workflow = createWorkflow({
|
|
286
|
+
name: 'Workflow In Nested Folder',
|
|
287
|
+
homeProject: {
|
|
288
|
+
id: 'project1',
|
|
289
|
+
type: 'team',
|
|
290
|
+
name: 'My Project',
|
|
291
|
+
icon: { type: 'icon', value: 'folder' },
|
|
292
|
+
createdAt: '2025-01-01',
|
|
293
|
+
updatedAt: '2025-01-01',
|
|
294
|
+
},
|
|
295
|
+
parentFolder: {
|
|
296
|
+
id: 'folder2',
|
|
297
|
+
name: 'Child Folder',
|
|
298
|
+
parentFolderId: 'folder1', // Has a grandparent folder
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const { getByTestId } = createComponent({
|
|
303
|
+
props: {
|
|
304
|
+
workflows: [workflow],
|
|
305
|
+
loading: false,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(getByTestId('workflow-location-project-name')).toHaveTextContent('My Project');
|
|
310
|
+
expect(getByTestId('workflow-location-folder-name')).toHaveTextContent('Child Folder');
|
|
311
|
+
expect(getByTestId('workflow-location-grandparent')).toBeVisible();
|
|
312
|
+
// Ellipsis separator (between ellipsis and folder)
|
|
313
|
+
expect(getByTestId('workflow-location-ellipsis-separator')).toBeVisible();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('Workflow description', () => {
|
|
318
|
+
it('should render workflow description when present', () => {
|
|
319
|
+
const workflow = createWorkflow({
|
|
320
|
+
description: 'This is a test workflow description',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const { getByTestId } = createComponent({
|
|
324
|
+
props: {
|
|
325
|
+
workflows: [workflow],
|
|
326
|
+
loading: false,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(getByTestId('mcp-workflow-description')).toHaveTextContent(
|
|
331
|
+
'This is a test workflow description',
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should show "Click to edit" tooltip when description is present', async () => {
|
|
336
|
+
const workflow = createWorkflow({
|
|
337
|
+
description: 'Short description',
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const { getByTestId } = createComponent({
|
|
341
|
+
props: {
|
|
342
|
+
workflows: [workflow],
|
|
343
|
+
loading: false,
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await userEvent.hover(getByTestId('mcp-workflow-description-cell'));
|
|
348
|
+
|
|
349
|
+
await waitFor(() => {
|
|
350
|
+
expect(getTooltip()).toHaveTextContent('Click to edit');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should render warning when workflow has no description', () => {
|
|
355
|
+
const workflow = createWorkflow({
|
|
356
|
+
description: undefined,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const { getByTestId, queryByTestId } = createComponent({
|
|
360
|
+
props: {
|
|
361
|
+
workflows: [workflow],
|
|
362
|
+
loading: false,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(getByTestId('mcp-workflow-description-empty')).toBeVisible();
|
|
367
|
+
expect(queryByTestId('mcp-workflow-description')).not.toBeInTheDocument();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should render warning when workflow description is empty string', () => {
|
|
371
|
+
const workflow = createWorkflow({
|
|
372
|
+
description: '',
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const { getByTestId, queryByTestId } = createComponent({
|
|
376
|
+
props: {
|
|
377
|
+
workflows: [workflow],
|
|
378
|
+
loading: false,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(getByTestId('mcp-workflow-description-empty')).toBeVisible();
|
|
383
|
+
expect(queryByTestId('mcp-workflow-description')).not.toBeInTheDocument();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('Actions menu', () => {
|
|
388
|
+
it('should enable "Remove Access" action when user has update permissions', async () => {
|
|
389
|
+
const workflow = createWorkflow({
|
|
390
|
+
scopes: ['workflow:read', 'workflow:update'],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const { getByTestId } = createComponent({
|
|
394
|
+
props: {
|
|
395
|
+
workflows: [workflow],
|
|
396
|
+
loading: false,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const actionToggle = getByTestId('mcp-workflow-action-toggle');
|
|
401
|
+
const toggleButton = within(actionToggle).getByRole('button');
|
|
402
|
+
await userEvent.click(toggleButton);
|
|
403
|
+
|
|
404
|
+
// Check that the action menu item is not disabled
|
|
405
|
+
const menuItems = document.querySelectorAll('[data-test-id="action-removeFromMCP"]');
|
|
406
|
+
expect(menuItems.length).toBe(1);
|
|
407
|
+
expect(menuItems[0]).not.toHaveAttribute('disabled');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should disable "Remove Access" action when user lacks update permissions', async () => {
|
|
411
|
+
const workflow = createWorkflow({
|
|
412
|
+
scopes: ['workflow:read'], // No workflow:update scope
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const { getByTestId } = createComponent({
|
|
416
|
+
props: {
|
|
417
|
+
workflows: [workflow],
|
|
418
|
+
loading: false,
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const actionToggle = getByTestId('mcp-workflow-action-toggle');
|
|
423
|
+
const toggleButton = within(actionToggle).getByRole('button');
|
|
424
|
+
await userEvent.click(toggleButton);
|
|
425
|
+
|
|
426
|
+
// Check that the action menu item is disabled
|
|
427
|
+
const menuItems = document.querySelectorAll('[data-test-id="action-removeFromMCP"]');
|
|
428
|
+
expect(menuItems.length).toBe(1);
|
|
429
|
+
expect(menuItems[0]).toHaveClass('is-disabled');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should emit removeMcpAccess event when action is clicked', async () => {
|
|
433
|
+
const workflow = createWorkflow({
|
|
434
|
+
scopes: ['workflow:read', 'workflow:update'],
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const { getByTestId, emitted } = createComponent({
|
|
438
|
+
props: {
|
|
439
|
+
workflows: [workflow],
|
|
440
|
+
loading: false,
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const actionToggle = getByTestId('mcp-workflow-action-toggle');
|
|
445
|
+
const toggleButton = within(actionToggle).getByRole('button');
|
|
446
|
+
await userEvent.click(toggleButton);
|
|
447
|
+
|
|
448
|
+
const menuItem = document.querySelector('[data-test-id="action-removeFromMCP"]');
|
|
449
|
+
expect(menuItem).not.toBeNull();
|
|
450
|
+
await userEvent.click(menuItem!);
|
|
451
|
+
|
|
452
|
+
expect(emitted('removeMcpAccess')).toBeTruthy();
|
|
453
|
+
expect(emitted('removeMcpAccess')[0]).toEqual([workflow]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('should enable "Update Description" action when user has update permissions', async () => {
|
|
457
|
+
const workflow = createWorkflow({
|
|
458
|
+
scopes: ['workflow:read', 'workflow:update'],
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const { getByTestId } = createComponent({
|
|
462
|
+
props: {
|
|
463
|
+
workflows: [workflow],
|
|
464
|
+
loading: false,
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const actionToggle = getByTestId('mcp-workflow-action-toggle');
|
|
469
|
+
const toggleButton = within(actionToggle).getByRole('button');
|
|
470
|
+
await userEvent.click(toggleButton);
|
|
471
|
+
|
|
472
|
+
const menuItems = document.querySelectorAll('[data-test-id="action-updateDescription"]');
|
|
473
|
+
expect(menuItems.length).toBe(1);
|
|
474
|
+
expect(menuItems[0]).not.toHaveAttribute('disabled');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should disable "Update Description" action when user lacks update permissions', async () => {
|
|
478
|
+
const workflow = createWorkflow({
|
|
479
|
+
scopes: ['workflow:read'],
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const { getByTestId } = createComponent({
|
|
483
|
+
props: {
|
|
484
|
+
workflows: [workflow],
|
|
485
|
+
loading: false,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const actionToggle = getByTestId('mcp-workflow-action-toggle');
|
|
490
|
+
const toggleButton = within(actionToggle).getByRole('button');
|
|
491
|
+
await userEvent.click(toggleButton);
|
|
492
|
+
|
|
493
|
+
const menuItems = document.querySelectorAll('[data-test-id="action-updateDescription"]');
|
|
494
|
+
expect(menuItems.length).toBe(1);
|
|
495
|
+
expect(menuItems[0]).toHaveClass('is-disabled');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('should emit updateDescription event when action is clicked', async () => {
|
|
499
|
+
const workflow = createWorkflow({
|
|
500
|
+
scopes: ['workflow:read', 'workflow:update'],
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const { getByTestId, emitted } = createComponent({
|
|
504
|
+
props: {
|
|
505
|
+
workflows: [workflow],
|
|
506
|
+
loading: false,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const actionToggle = getByTestId('mcp-workflow-action-toggle');
|
|
511
|
+
const toggleButton = within(actionToggle).getByRole('button');
|
|
512
|
+
await userEvent.click(toggleButton);
|
|
513
|
+
|
|
514
|
+
const menuItem = document.querySelector('[data-test-id="action-updateDescription"]');
|
|
515
|
+
expect(menuItem).not.toBeNull();
|
|
516
|
+
await userEvent.click(menuItem!);
|
|
517
|
+
|
|
518
|
+
expect(emitted('updateDescription')).toBeTruthy();
|
|
519
|
+
expect(emitted('updateDescription')[0]).toEqual([workflow]);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe('Workflow links', () => {
|
|
524
|
+
it('should render workflow name as link to workflow editor', () => {
|
|
525
|
+
const workflow = createWorkflow({
|
|
526
|
+
id: 'workflow-123',
|
|
527
|
+
name: 'My Workflow',
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const { getByTestId } = createComponent({
|
|
531
|
+
props: {
|
|
532
|
+
workflows: [workflow],
|
|
533
|
+
loading: false,
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const workflowLink = getByTestId('mcp-workflow-name-link');
|
|
538
|
+
expect(workflowLink).toHaveAttribute('href', '/workflows/workflow-123');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('should render project name as link to project workflows', () => {
|
|
542
|
+
const workflow = createWorkflow({
|
|
543
|
+
homeProject: {
|
|
544
|
+
id: 'project-456',
|
|
545
|
+
type: 'team',
|
|
546
|
+
name: 'Team Project',
|
|
547
|
+
icon: { type: 'icon', value: 'folder' },
|
|
548
|
+
createdAt: '2025-01-01',
|
|
549
|
+
updatedAt: '2025-01-01',
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const { getByTestId } = createComponent({
|
|
554
|
+
props: {
|
|
555
|
+
workflows: [workflow],
|
|
556
|
+
loading: false,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const projectLink = getByTestId('workflow-location-project-link');
|
|
561
|
+
expect(projectLink).toHaveAttribute('href', '/projects/project-456');
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('Multiple workflows', () => {
|
|
566
|
+
it('should render multiple workflows in the table', () => {
|
|
567
|
+
const workflows = [
|
|
568
|
+
createWorkflow({
|
|
569
|
+
id: 'workflow-1',
|
|
570
|
+
name: 'First Workflow',
|
|
571
|
+
description: 'First description',
|
|
572
|
+
homeProject: {
|
|
573
|
+
id: 'project1',
|
|
574
|
+
type: 'team',
|
|
575
|
+
name: 'Project A',
|
|
576
|
+
icon: { type: 'icon', value: 'folder' },
|
|
577
|
+
createdAt: '2025-01-01',
|
|
578
|
+
updatedAt: '2025-01-01',
|
|
579
|
+
},
|
|
580
|
+
}),
|
|
581
|
+
createWorkflow({
|
|
582
|
+
id: 'workflow-2',
|
|
583
|
+
name: 'Second Workflow',
|
|
584
|
+
description: undefined,
|
|
585
|
+
homeProject: {
|
|
586
|
+
id: 'project2',
|
|
587
|
+
type: 'personal',
|
|
588
|
+
name: 'User<user@n8n.io>',
|
|
589
|
+
icon: { type: 'icon', value: 'user' },
|
|
590
|
+
createdAt: '2025-01-01',
|
|
591
|
+
updatedAt: '2025-01-01',
|
|
592
|
+
},
|
|
593
|
+
parentFolder: {
|
|
594
|
+
id: 'folder1',
|
|
595
|
+
name: 'My Folder',
|
|
596
|
+
parentFolderId: null,
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
599
|
+
createWorkflow({
|
|
600
|
+
id: 'workflow-3',
|
|
601
|
+
name: 'Third Workflow',
|
|
602
|
+
description: 'Third description',
|
|
603
|
+
homeProject: {
|
|
604
|
+
id: 'project3',
|
|
605
|
+
type: 'team',
|
|
606
|
+
name: 'Project B',
|
|
607
|
+
icon: { type: 'icon', value: 'folder' },
|
|
608
|
+
createdAt: '2025-01-01',
|
|
609
|
+
updatedAt: '2025-01-01',
|
|
610
|
+
},
|
|
611
|
+
parentFolder: {
|
|
612
|
+
id: 'folder2',
|
|
613
|
+
name: 'Nested Folder',
|
|
614
|
+
parentFolderId: 'folder1',
|
|
615
|
+
},
|
|
616
|
+
}),
|
|
617
|
+
];
|
|
618
|
+
|
|
619
|
+
const { getAllByTestId } = createComponent({
|
|
620
|
+
props: {
|
|
621
|
+
workflows,
|
|
622
|
+
loading: false,
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Verify all workflows are rendered
|
|
627
|
+
const workflowCells = getAllByTestId('mcp-workflow-cell');
|
|
628
|
+
expect(workflowCells).toHaveLength(3);
|
|
629
|
+
|
|
630
|
+
// Verify workflow names
|
|
631
|
+
const workflowNames = getAllByTestId('mcp-workflow-name');
|
|
632
|
+
expect(workflowNames).toHaveLength(3);
|
|
633
|
+
expect(workflowNames[0]).toHaveTextContent('First Workflow');
|
|
634
|
+
expect(workflowNames[1]).toHaveTextContent('Second Workflow');
|
|
635
|
+
expect(workflowNames[2]).toHaveTextContent('Third Workflow');
|
|
636
|
+
|
|
637
|
+
// Verify project names (second one should show "Personal")
|
|
638
|
+
const projectNames = getAllByTestId('workflow-location-project-name');
|
|
639
|
+
expect(projectNames).toHaveLength(3);
|
|
640
|
+
expect(projectNames[0]).toHaveTextContent('Project A');
|
|
641
|
+
expect(projectNames[1]).toHaveTextContent('Personal');
|
|
642
|
+
expect(projectNames[2]).toHaveTextContent('Project B');
|
|
643
|
+
|
|
644
|
+
// Verify descriptions (second workflow has no description, so warning should show)
|
|
645
|
+
const descriptions = getAllByTestId('mcp-workflow-description');
|
|
646
|
+
expect(descriptions).toHaveLength(2);
|
|
647
|
+
const emptyDescriptions = getAllByTestId('mcp-workflow-description-empty');
|
|
648
|
+
expect(emptyDescriptions).toHaveLength(1);
|
|
649
|
+
|
|
650
|
+
// Verify folder names (only workflows 2 and 3 have folders)
|
|
651
|
+
const folderNames = getAllByTestId('workflow-location-folder-name');
|
|
652
|
+
expect(folderNames).toHaveLength(2);
|
|
653
|
+
expect(folderNames[0]).toHaveTextContent('My Folder');
|
|
654
|
+
expect(folderNames[1]).toHaveTextContent('Nested Folder');
|
|
655
|
+
|
|
656
|
+
// Verify ellipsis for nested folder (only workflow 3)
|
|
657
|
+
const grandparentFolders = getAllByTestId('workflow-location-grandparent');
|
|
658
|
+
expect(grandparentFolders).toHaveLength(1);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
});
|