mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
feat(editor): Introduce new project/folder actions menu (#30614)
This commit is contained in:
parent
e9649d21fd
commit
f0ea4ed1f0
|
|
@ -357,9 +357,11 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.updatedCount).toBe(2);
|
||||
expect(response.body.data.unchangedCount).toBe(0);
|
||||
expect(response.body.data.updatedIds.sort()).toEqual(
|
||||
[ownedByMember.id, ownedByMember2.id].sort(),
|
||||
);
|
||||
expect(response.body.data.unchangedIds).toEqual([]);
|
||||
expect(response.body.data.skippedCount).toBe(1);
|
||||
|
||||
expect(await readAvailableInMCP(ownedByMember.id)).toBe(true);
|
||||
|
|
@ -389,6 +391,8 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.updatedIds).toEqual([live.id]);
|
||||
expect(response.body.data.unchangedIds).toEqual([]);
|
||||
expect(response.body.data.unchangedCount).toBe(0);
|
||||
expect(response.body.data.skippedCount).toBe(1);
|
||||
expect(await readAvailableInMCP(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
|
@ -407,7 +411,12 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
.send({ availableInMCP: true, projectId: project.id });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data).toEqual({ updatedCount: 2, skippedCount: 0, failedCount: 0 });
|
||||
expect(response.body.data).toEqual({
|
||||
updatedCount: 2,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
expect(await readAvailableInMCP(projectWf1.id)).toBe(true);
|
||||
expect(await readAvailableInMCP(projectWf2.id)).toBe(true);
|
||||
expect(await readAvailableInMCP(unrelatedWf.id)).toBeUndefined();
|
||||
|
|
@ -437,7 +446,12 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
.send({ availableInMCP: true, folderId: rootFolder.id });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data).toEqual({ updatedCount: 2, skippedCount: 0, failedCount: 0 });
|
||||
expect(response.body.data).toEqual({
|
||||
updatedCount: 2,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
expect(await readAvailableInMCP(workflowInRoot.id)).toBe(true);
|
||||
expect(await readAvailableInMCP(workflowInChild.id)).toBe(true);
|
||||
expect(await readAvailableInMCP(workflowOutsideFolder.id)).toBeUndefined();
|
||||
|
|
@ -470,9 +484,10 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
.send({ availableInMCP: true, folderId: folder.id });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
// Folder-scoped — `updatedIds` is omitted from the response.
|
||||
// Folder-scoped — workflow id arrays are omitted from the response.
|
||||
expect(response.body.data).toEqual({
|
||||
updatedCount: 1,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
@ -492,6 +507,8 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.updatedIds).toEqual([wf.id]);
|
||||
expect(response.body.data.unchangedIds).toEqual([]);
|
||||
expect(response.body.data.unchangedCount).toBe(0);
|
||||
expect(await readAvailableInMCP(wf.id)).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -514,13 +531,13 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.updatedCount).toBe(2);
|
||||
expect(response.body.data.updatedIds.sort()).toEqual(
|
||||
[alreadyEnabled.id, freshlyChanged.id].sort(),
|
||||
);
|
||||
expect(response.body.data.updatedCount).toBe(1);
|
||||
expect(response.body.data.unchangedCount).toBe(1);
|
||||
expect(response.body.data.updatedIds).toEqual([freshlyChanged.id]);
|
||||
expect(response.body.data.unchangedIds).toEqual([alreadyEnabled.id]);
|
||||
expect(response.body.data.skippedCount).toBe(0);
|
||||
|
||||
// Re-submitting the same request is a no-op but still reports both as updated.
|
||||
// Re-submitting the same request is a no-op and reports both as unchanged.
|
||||
const second = await testServer
|
||||
.authAgentFor(toggleOwner)
|
||||
.patch('/mcp/workflows/toggle-access')
|
||||
|
|
@ -530,7 +547,12 @@ describe('PATCH /mcp/workflows/toggle-access', () => {
|
|||
});
|
||||
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.body.data.updatedCount).toBe(2);
|
||||
expect(second.body.data.updatedCount).toBe(0);
|
||||
expect(second.body.data.unchangedCount).toBe(2);
|
||||
expect(second.body.data.updatedIds).toEqual([]);
|
||||
expect(second.body.data.unchangedIds.sort()).toEqual(
|
||||
[alreadyEnabled.id, freshlyChanged.id].sort(),
|
||||
);
|
||||
expect(second.body.data.skippedCount).toBe(0);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -329,6 +329,8 @@ describe('McpSettingsController', () => {
|
|||
const bulkResult = {
|
||||
updatedCount: 2,
|
||||
updatedIds: ['wf-1', 'wf-2'],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [
|
||||
|
|
@ -357,6 +359,8 @@ describe('McpSettingsController', () => {
|
|||
expect(result).toEqual({
|
||||
updatedCount: 2,
|
||||
updatedIds: ['wf-1', 'wf-2'],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ describe('McpSettingsService', () => {
|
|||
expect(result).toEqual({
|
||||
updatedCount: 2,
|
||||
updatedIds: ['wf-1', 'wf-2'],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
// wf-unauthorized was in the request but filtered out — counts as skipped.
|
||||
skippedCount: 1,
|
||||
failedCount: 0,
|
||||
|
|
@ -285,6 +287,8 @@ describe('McpSettingsService', () => {
|
|||
expect(result).toEqual({
|
||||
updatedCount: 1,
|
||||
updatedIds: ['wf-1'],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
skippedCount: 1,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [
|
||||
|
|
@ -321,11 +325,11 @@ describe('McpSettingsService', () => {
|
|||
expect(stubs.find).toHaveBeenCalledTimes(2);
|
||||
expect(stubs.find.mock.calls[0][1].select).toEqual(['id', 'settings']);
|
||||
expect(stubs.find.mock.calls[1][1].where.id.value).toEqual(['wf-1']);
|
||||
// Both ids are reported as updated (the DB is in the requested state
|
||||
// for both). No-ops go last in the list.
|
||||
expect(result).toEqual({
|
||||
updatedCount: 2,
|
||||
updatedIds: ['wf-1', 'wf-2'],
|
||||
updatedCount: 1,
|
||||
updatedIds: ['wf-1'],
|
||||
unchangedCount: 1,
|
||||
unchangedIds: ['wf-2'],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [
|
||||
|
|
@ -358,8 +362,10 @@ describe('McpSettingsService', () => {
|
|||
expect(stubs.update).not.toHaveBeenCalled();
|
||||
expect(stubs.find).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
updatedCount: 2,
|
||||
updatedIds: ['wf-1', 'wf-2'],
|
||||
updatedCount: 0,
|
||||
updatedIds: [],
|
||||
unchangedCount: 2,
|
||||
unchangedIds: ['wf-1', 'wf-2'],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [],
|
||||
|
|
@ -408,13 +414,14 @@ describe('McpSettingsService', () => {
|
|||
expect(stubs.manager.transaction).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('omits updatedIds from the response when scoped by projectId', async () => {
|
||||
test('omits workflow ids from the response when scoped by projectId', async () => {
|
||||
setupRepository([
|
||||
{ id: 'wf-1', settings: {} },
|
||||
{ id: 'wf-2', settings: {} },
|
||||
|
|
@ -430,6 +437,7 @@ describe('McpSettingsService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
updatedCount: 2,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [
|
||||
|
|
@ -446,9 +454,10 @@ describe('McpSettingsService', () => {
|
|||
],
|
||||
});
|
||||
expect(result).not.toHaveProperty('updatedIds');
|
||||
expect(result).not.toHaveProperty('unchangedIds');
|
||||
});
|
||||
|
||||
test('omits updatedIds from the response when scoped by folderId', async () => {
|
||||
test('omits workflow ids from the response when scoped by folderId', async () => {
|
||||
setupRepository([{ id: 'wf-1', settings: {} }]);
|
||||
workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(['wf-1']);
|
||||
|
||||
|
|
@ -461,6 +470,7 @@ describe('McpSettingsService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
updatedCount: 1,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [
|
||||
|
|
@ -472,6 +482,7 @@ describe('McpSettingsService', () => {
|
|||
],
|
||||
});
|
||||
expect(result).not.toHaveProperty('updatedIds');
|
||||
expect(result).not.toHaveProperty('unchangedIds');
|
||||
});
|
||||
|
||||
test('resolves candidates via findAllWorkflowIdsForUser when scoped by folderId', async () => {
|
||||
|
|
@ -515,6 +526,7 @@ describe('McpSettingsService', () => {
|
|||
expect(stubs.manager.transaction).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [],
|
||||
|
|
@ -535,14 +547,16 @@ describe('McpSettingsService', () => {
|
|||
expect(stubs.manager.transaction).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [],
|
||||
});
|
||||
expect(result).not.toHaveProperty('updatedIds');
|
||||
expect(result).not.toHaveProperty('unchangedIds');
|
||||
});
|
||||
|
||||
test('returns an empty updatedIds array when scoped by workflowIds and none are accessible', async () => {
|
||||
test('returns empty id arrays when scoped by workflowIds and none are accessible', async () => {
|
||||
setupRepository([]);
|
||||
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set());
|
||||
|
||||
|
|
@ -555,10 +569,12 @@ describe('McpSettingsService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 2,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [],
|
||||
updatedIds: [],
|
||||
unchangedIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -629,7 +645,7 @@ describe('McpSettingsService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('surfaces chunk failures in updatedIds when scoped by workflowIds', async () => {
|
||||
test('surfaces chunk failures in id arrays when scoped by workflowIds', async () => {
|
||||
const stubs = setupRepository([{ id: 'wf-1', settings: {} }]);
|
||||
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set(['wf-1']));
|
||||
|
||||
|
|
@ -646,10 +662,12 @@ describe('McpSettingsService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 1,
|
||||
changedWorkflows: [],
|
||||
updatedIds: [],
|
||||
unchangedIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ const WORKFLOW_SETTINGS_FIELDS: Array<keyof WorkflowEntity> = ['id', 'settings']
|
|||
|
||||
type BulkSetAvailableInMCPResult = {
|
||||
updatedCount: number;
|
||||
unchangedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
changedWorkflows: WorkflowMCPAvailabilityChange[];
|
||||
updatedIds?: string[];
|
||||
unchangedIds?: string[];
|
||||
};
|
||||
|
||||
type WorkflowMCPAvailabilityChange = {
|
||||
|
|
@ -98,10 +100,11 @@ export class McpSettingsService {
|
|||
if (candidateIds.length === 0) {
|
||||
return {
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: baselineSize,
|
||||
failedCount: 0,
|
||||
changedWorkflows: [],
|
||||
...(isWorkflowIdsScope ? { updatedIds: [] } : {}),
|
||||
...(isWorkflowIdsScope ? { updatedIds: [], unchangedIds: [] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -188,14 +191,13 @@ export class McpSettingsService {
|
|||
}
|
||||
}
|
||||
|
||||
const confirmedIds = [...writtenIds, ...noOpIds];
|
||||
|
||||
return {
|
||||
updatedCount: confirmedIds.length,
|
||||
skippedCount: Math.max(0, baselineSize - confirmedIds.length - failedCount),
|
||||
updatedCount: writtenIds.length,
|
||||
unchangedCount: noOpIds.length,
|
||||
skippedCount: Math.max(0, baselineSize - writtenIds.length - noOpIds.length - failedCount),
|
||||
failedCount,
|
||||
changedWorkflows,
|
||||
...(isWorkflowIdsScope ? { updatedIds: confirmedIds } : {}),
|
||||
...(isWorkflowIdsScope ? { updatedIds: writtenIds, unchangedIds: noOpIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4061,6 +4061,29 @@
|
|||
"workflows.heading": "Workflows",
|
||||
"workflows.add": "Add workflow",
|
||||
"workflows.addFolder": "Add folder",
|
||||
"resourceActions.mcpAccess.manage": "Manage MCP access",
|
||||
"resourceActions.mcpAccess.enable": "Enable MCP access",
|
||||
"resourceActions.mcpAccess.disable": "Disable MCP access",
|
||||
"resourceActions.mcpAccess.enable.tooltip": "Allow MCP access for all workflows in '{scopeName}'",
|
||||
"resourceActions.mcpAccess.disable.tooltip": "Disable MCP access for all workflows in '{scopeName}'",
|
||||
"resourceActions.mcpAccess.success.enabled.title": "MCP access enabled",
|
||||
"resourceActions.mcpAccess.success.disabled.title": "MCP access disabled",
|
||||
"resourceActions.mcpAccess.noChanges.title": "No changes needed",
|
||||
"resourceActions.mcpAccess.partial.title": "Some workflows weren't updated",
|
||||
"resourceActions.mcpAccess.noWorkflows.title": "No workflows to update",
|
||||
"resourceActions.mcpAccess.outcome.message": "{summary}<br><br><a href=\"{link}\">Manage MCP settings</a>",
|
||||
"resourceActions.mcpAccess.summary.updated.enabled": "Enabled MCP access for 1 workflow in '{scopeName}' | Enabled MCP access for {count} workflows in '{scopeName}'",
|
||||
"resourceActions.mcpAccess.summary.updated.disabled": "Disabled MCP access for 1 workflow in '{scopeName}' | Disabled MCP access for {count} workflows in '{scopeName}'",
|
||||
"resourceActions.mcpAccess.summary.unchanged.enabled": "1 workflow in '{scopeName}' already has MCP access enabled | {count} workflows in '{scopeName}' already have MCP access enabled",
|
||||
"resourceActions.mcpAccess.summary.unchanged.disabled": "1 workflow in '{scopeName}' already doesn't have MCP access | {count} workflows in '{scopeName}' already don't have MCP access",
|
||||
"resourceActions.mcpAccess.summary.skipped": "1 workflow in '{scopeName}' couldn't be updated | {count} workflows in '{scopeName}' couldn't be updated",
|
||||
"resourceActions.mcpAccess.noWorkflows.message": "There are no workflows in '{scopeName}' that can be updated.<br><br><a href=\"{link}\">Manage MCP settings</a>",
|
||||
"resourceActions.mcpAccess.error.enabled.title": "Error enabling MCP access",
|
||||
"resourceActions.mcpAccess.error.disabled.title": "Error disabling MCP access",
|
||||
"resourceActions.mcpAccess.error.enabled.message": "Could not enable MCP access for all workflows in '{scopeName}'.<br><br><a href=\"{link}\">Manage MCP settings</a>",
|
||||
"resourceActions.mcpAccess.error.disabled.message": "Could not disable MCP access for all workflows in '{scopeName}'.<br><br><a href=\"{link}\">Manage MCP settings</a>",
|
||||
"resourceActions.mcpAccess.disabled.title": "MCP access is off",
|
||||
"resourceActions.mcpAccess.disabled.message": "Turn on instance-level MCP access before managing workflow access<br><br><a href=\"{link}\">Manage MCP settings</a>",
|
||||
"workflows.publish": "Publish",
|
||||
"workflows.published": "Published",
|
||||
"workflows.publish.permissionDenied": "You don't have permission to publish this workflow",
|
||||
|
|
|
|||
|
|
@ -626,6 +626,7 @@ describe('WorkflowCard', () => {
|
|||
mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({
|
||||
updatedCount: 1,
|
||||
skippedCount: 0,
|
||||
unchangedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
||||
|
|
@ -725,6 +726,7 @@ describe('WorkflowCard', () => {
|
|||
mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({
|
||||
updatedCount: 1,
|
||||
skippedCount: 0,
|
||||
unchangedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
||||
|
|
@ -1009,6 +1011,7 @@ describe('WorkflowCard', () => {
|
|||
mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({
|
||||
updatedCount: 1,
|
||||
skippedCount: 0,
|
||||
unchangedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -497,20 +497,28 @@ describe('Folders', () => {
|
|||
expect(getByTestId('folder-card-name')).toHaveTextContent(TEST_FOLDER_RESOURCE.name);
|
||||
});
|
||||
|
||||
it('should show "Create folder" button when not in the overview or sharing pages', async () => {
|
||||
it('should show folder actions menu when not in the overview or sharing pages', async () => {
|
||||
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.currentProject = {
|
||||
id: '1',
|
||||
name: 'Project 1',
|
||||
type: 'team',
|
||||
scopes: ['folder:create'],
|
||||
} as Project;
|
||||
|
||||
workflowsListStore.fetchWorkflowsPage.mockResolvedValue([TEST_WORKFLOW_RESOURCE]);
|
||||
const { getByTestId } = renderComponent({
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
expect(getByTestId('add-folder-button')).toBeInTheDocument();
|
||||
expect(queryByTestId('add-folder-button')).not.toBeInTheDocument();
|
||||
expect(getByTestId('folder-breadcrumbs-actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show "Create folder" button when in overview subpage', async () => {
|
||||
it('should NOT show standalone "Create folder" button when in overview subpage', async () => {
|
||||
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
|
||||
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
||||
|
||||
|
|
@ -523,7 +531,7 @@ describe('Folders', () => {
|
|||
expect(queryByTestId('add-folder-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show "Create folder" button when in shared subpage', async () => {
|
||||
it('should NOT show standalone "Create folder" button when in shared subpage', async () => {
|
||||
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import Draggable from '@/app/components/Draggable.vue';
|
|||
import EmptySharedSectionActionBox from '@/features/core/folders/components/EmptySharedSectionActionBox.vue';
|
||||
import FolderBreadcrumbs from '@/features/core/folders/components/FolderBreadcrumbs.vue';
|
||||
import FolderCard from '@/features/core/folders/components/FolderCard.vue';
|
||||
import { FOLDER_LIST_ITEM_ACTIONS } from '@/features/core/folders/folders.constants';
|
||||
import {
|
||||
FOLDER_LIST_ITEM_ACTIONS,
|
||||
MCP_ACCESS_ACTIONS,
|
||||
} from '@/features/core/folders/folders.constants';
|
||||
import ResourcesListLayout from '@/app/components/layouts/ResourcesListLayout.vue';
|
||||
import ProjectHeader from '@/features/collaboration/projects/components/ProjectHeader.vue';
|
||||
import WorkflowCard from '@/app/components/WorkflowCard.vue';
|
||||
|
|
@ -72,6 +75,9 @@ import { useUsageStore } from '@/features/settings/usage/usage.store';
|
|||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
|
||||
import { MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants';
|
||||
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
|
||||
import type { ToggleWorkflowsMcpAccessResponse } from '@/features/ai/mcpAccess/mcp.api';
|
||||
import {
|
||||
type Project,
|
||||
type ProjectSharingData,
|
||||
|
|
@ -139,6 +145,7 @@ const sourceControlStore = useSourceControlStore();
|
|||
const usersStore = useUsersStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowsListStore = useWorkflowsListStore();
|
||||
const mcpStore = useMCPStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
|
@ -183,6 +190,17 @@ const filters = ref<Filters>({
|
|||
|
||||
const workflowListEventBus = createEventBus<WorkflowListEventMap>();
|
||||
|
||||
type BreadcrumbAction = UserAction<IUser> & {
|
||||
children?: BreadcrumbAction[];
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
type McpAccessScope = {
|
||||
type: 'folder' | 'project';
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ResourcesListLayoutExpose = {
|
||||
getScrollContainer?: () => HTMLElement | null;
|
||||
};
|
||||
|
|
@ -303,6 +321,10 @@ const currentFolderParent = computed(() => {
|
|||
: null;
|
||||
});
|
||||
|
||||
const showMainBreadcrumbs = computed(() => {
|
||||
return showFolders.value && (!currentFolderId.value || currentFolder.value !== null);
|
||||
});
|
||||
|
||||
const isDragging = computed(() => {
|
||||
return foldersStore.draggedElement !== null;
|
||||
});
|
||||
|
|
@ -324,18 +346,21 @@ useAutoScrollOnDrag({
|
|||
});
|
||||
|
||||
const hasPermissionToCreateFolders = computed(() => {
|
||||
if (!currentProject.value) return false;
|
||||
return getResourcePermissions(currentProject.value.scopes).folder.create === true;
|
||||
const project = currentProject.value ?? projectsStore.personalProject;
|
||||
if (!project) return false;
|
||||
return getResourcePermissions(project.scopes).folder.create === true;
|
||||
});
|
||||
|
||||
const hasPermissionToUpdateFolders = computed(() => {
|
||||
if (!currentProject.value) return false;
|
||||
return getResourcePermissions(currentProject.value.scopes).folder.update === true;
|
||||
const project = currentProject.value ?? projectsStore.personalProject;
|
||||
if (!project) return false;
|
||||
return getResourcePermissions(project.scopes).folder.update === true;
|
||||
});
|
||||
|
||||
const hasPermissionToDeleteFolders = computed(() => {
|
||||
if (!currentProject.value) return false;
|
||||
return getResourcePermissions(currentProject.value.scopes).folder.delete === true;
|
||||
const project = currentProject.value ?? projectsStore.personalProject;
|
||||
if (!project) return false;
|
||||
return getResourcePermissions(project.scopes).folder.delete === true;
|
||||
});
|
||||
|
||||
const hasPermissionToCreateWorkflows = computed(() => {
|
||||
|
|
@ -343,20 +368,126 @@ const hasPermissionToCreateWorkflows = computed(() => {
|
|||
return getResourcePermissions(currentProject.value.scopes).workflow.create === true;
|
||||
});
|
||||
|
||||
const currentProject = computed(() => projectsStore.currentProject);
|
||||
|
||||
const projectName = computed(() => {
|
||||
if (currentProject.value?.type === ProjectTypes.Personal) {
|
||||
return i18n.baseText('projects.menu.personal');
|
||||
}
|
||||
return currentProject.value?.name;
|
||||
const hasPermissionToUpdateWorkflows = computed(() => {
|
||||
const project = currentProject.value ?? projectsStore.personalProject;
|
||||
if (!project) return false;
|
||||
return getResourcePermissions(project.scopes).workflow.update === true;
|
||||
});
|
||||
|
||||
const currentParentName = computed(() => {
|
||||
if (currentFolder.value) {
|
||||
return currentFolder.value.name;
|
||||
const currentProject = computed(() => projectsStore.currentProject);
|
||||
|
||||
const currentBreadcrumbsProject = computed(
|
||||
() => currentProject.value ?? projectsStore.personalProject,
|
||||
);
|
||||
|
||||
const currentBreadcrumbsProjectName = computed(() => {
|
||||
const project = currentBreadcrumbsProject.value;
|
||||
if (!project) return undefined;
|
||||
|
||||
return project.type === ProjectTypes.Personal
|
||||
? i18n.baseText('projects.menu.personal')
|
||||
: project.name;
|
||||
});
|
||||
|
||||
const currentParentName = computed(
|
||||
() => currentFolder.value?.name ?? currentBreadcrumbsProjectName.value,
|
||||
);
|
||||
|
||||
const projectRootBreadcrumbsActions = computed<Array<UserAction<IUser>>>(() => {
|
||||
const project = currentBreadcrumbsProject.value;
|
||||
if (!project) return [];
|
||||
|
||||
const actions: Array<UserAction<IUser>> = [
|
||||
{
|
||||
label: i18n.baseText('folders.actions.create'),
|
||||
value: FOLDER_LIST_ITEM_ACTIONS.CREATE,
|
||||
disabled: readOnlyEnv.value || !hasPermissionToCreateFolders.value,
|
||||
},
|
||||
];
|
||||
|
||||
if (project.type !== ProjectTypes.Personal) {
|
||||
actions.push({
|
||||
label: favoritesStore.isFavorite(project.id, 'project')
|
||||
? i18n.baseText('favorites.remove')
|
||||
: i18n.baseText('favorites.add'),
|
||||
value: FOLDER_LIST_ITEM_ACTIONS.TOGGLE_FAVORITE,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
return projectName.value;
|
||||
|
||||
return actions;
|
||||
});
|
||||
|
||||
const mcpAccessScope = computed<McpAccessScope | null>(() => {
|
||||
if (currentFolderId.value) {
|
||||
if (!currentFolder.value) return null;
|
||||
|
||||
return {
|
||||
type: 'folder' as const,
|
||||
id: currentFolder.value.id,
|
||||
name: currentFolder.value.name,
|
||||
};
|
||||
}
|
||||
|
||||
const project = currentBreadcrumbsProject.value;
|
||||
const name = currentBreadcrumbsProjectName.value;
|
||||
|
||||
if (!project?.id || !name) return null;
|
||||
|
||||
return {
|
||||
type: 'project' as const,
|
||||
id: project.id,
|
||||
name,
|
||||
};
|
||||
});
|
||||
|
||||
const showMcpAccessActions = computed(
|
||||
() =>
|
||||
mcpModuleActive.value &&
|
||||
mcpAccessScope.value !== null &&
|
||||
!projectPages.isOverviewSubPage &&
|
||||
!projectPages.isSharedSubPage &&
|
||||
!readOnlyEnv.value &&
|
||||
hasPermissionToUpdateWorkflows.value,
|
||||
);
|
||||
|
||||
const settingsLink = computed(() => router.resolve({ name: MCP_SETTINGS_VIEW }).href);
|
||||
|
||||
const mcpAccessBreadcrumbsAction = computed<BreadcrumbAction | null>(() => {
|
||||
if (!showMcpAccessActions.value || !mcpAccessScope.value) return null;
|
||||
|
||||
return {
|
||||
label: i18n.baseText('resourceActions.mcpAccess.manage'),
|
||||
value: MCP_ACCESS_ACTIONS.MANAGE,
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
label: i18n.baseText('resourceActions.mcpAccess.enable'),
|
||||
value: MCP_ACCESS_ACTIONS.ENABLE,
|
||||
disabled: false,
|
||||
tooltip: i18n.baseText('resourceActions.mcpAccess.enable.tooltip', {
|
||||
interpolate: { scopeName: mcpAccessScope.value.name },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('resourceActions.mcpAccess.disable'),
|
||||
value: MCP_ACCESS_ACTIONS.DISABLE,
|
||||
disabled: false,
|
||||
tooltip: i18n.baseText('resourceActions.mcpAccess.disable.tooltip', {
|
||||
interpolate: { scopeName: mcpAccessScope.value.name },
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const breadcrumbsActions = computed<BreadcrumbAction[]>(() => {
|
||||
const actions = currentFolder.value
|
||||
? mainBreadcrumbsActions.value
|
||||
: projectRootBreadcrumbsActions.value;
|
||||
const mcpAction = mcpAccessBreadcrumbsAction.value;
|
||||
|
||||
return mcpAction ? [...actions, mcpAction] : actions;
|
||||
});
|
||||
|
||||
const personalProject = computed<Project | null>(() => {
|
||||
|
|
@ -1216,9 +1347,15 @@ const onBreadcrumbItemClick = (item: PathItem) => {
|
|||
// These render next to the breadcrumbs and are applied to the current folder/project
|
||||
const onBreadCrumbsAction = async (action: string) => {
|
||||
switch (action) {
|
||||
case MCP_ACCESS_ACTIONS.ENABLE:
|
||||
await toggleMcpAccess(true);
|
||||
break;
|
||||
case MCP_ACCESS_ACTIONS.DISABLE:
|
||||
await toggleMcpAccess(false);
|
||||
break;
|
||||
case FOLDER_LIST_ITEM_ACTIONS.CREATE:
|
||||
if (!route.params.projectId) return;
|
||||
const currentParent = currentFolder.value?.name || projectName.value;
|
||||
if (!currentBreadcrumbsProject.value) return;
|
||||
const currentParent = currentFolder.value?.name || currentBreadcrumbsProjectName.value;
|
||||
if (!currentParent) return;
|
||||
await createFolder({
|
||||
id: (route.params.folderId as string) ?? '-1',
|
||||
|
|
@ -1241,6 +1378,8 @@ const onBreadCrumbsAction = async (action: string) => {
|
|||
case FOLDER_LIST_ITEM_ACTIONS.TOGGLE_FAVORITE:
|
||||
if (currentFolder.value) {
|
||||
await favoritesStore.toggleFavorite(currentFolder.value.id, 'folder');
|
||||
} else if (currentBreadcrumbsProject.value) {
|
||||
await favoritesStore.toggleFavorite(currentBreadcrumbsProject.value.id, 'project');
|
||||
}
|
||||
break;
|
||||
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
|
||||
|
|
@ -1263,12 +1402,198 @@ const onBreadCrumbsAction = async (action: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
function getMcpAccessTarget(scope: McpAccessScope | null = mcpAccessScope.value) {
|
||||
if (!scope) return null;
|
||||
|
||||
return scope.type === 'folder' ? { folderId: scope.id } : { projectId: scope.id };
|
||||
}
|
||||
|
||||
function openSettingsFromToast(event?: MouseEvent) {
|
||||
if (!(event?.target instanceof HTMLAnchorElement)) return;
|
||||
|
||||
event.preventDefault();
|
||||
void router.push(settingsLink.value);
|
||||
}
|
||||
|
||||
function getMCPAccessUpdatedSummary(enabled: boolean, count: number, scopeName: string) {
|
||||
return enabled
|
||||
? i18n.baseText('resourceActions.mcpAccess.summary.updated.enabled', {
|
||||
adjustToNumber: count,
|
||||
interpolate: { count: String(count), scopeName },
|
||||
})
|
||||
: i18n.baseText('resourceActions.mcpAccess.summary.updated.disabled', {
|
||||
adjustToNumber: count,
|
||||
interpolate: { count: String(count), scopeName },
|
||||
});
|
||||
}
|
||||
|
||||
function getMCPAccessUnchangedSummary(enabled: boolean, count: number, scopeName: string) {
|
||||
return enabled
|
||||
? i18n.baseText('resourceActions.mcpAccess.summary.unchanged.enabled', {
|
||||
adjustToNumber: count,
|
||||
interpolate: { count: String(count), scopeName },
|
||||
})
|
||||
: i18n.baseText('resourceActions.mcpAccess.summary.unchanged.disabled', {
|
||||
adjustToNumber: count,
|
||||
interpolate: { count: String(count), scopeName },
|
||||
});
|
||||
}
|
||||
|
||||
function getMCPAccessSkippedSummary(count: number, scopeName: string) {
|
||||
return i18n.baseText('resourceActions.mcpAccess.summary.skipped', {
|
||||
adjustToNumber: count,
|
||||
interpolate: { count: String(count), scopeName },
|
||||
});
|
||||
}
|
||||
|
||||
function getMCPAccessOutcomeMessage(
|
||||
enabled: boolean,
|
||||
response: ToggleWorkflowsMcpAccessResponse,
|
||||
scopeName: string,
|
||||
) {
|
||||
const summaries: string[] = [];
|
||||
|
||||
if (response.updatedCount > 0) {
|
||||
summaries.push(getMCPAccessUpdatedSummary(enabled, response.updatedCount, scopeName));
|
||||
}
|
||||
|
||||
if (response.unchangedCount > 0) {
|
||||
summaries.push(getMCPAccessUnchangedSummary(enabled, response.unchangedCount, scopeName));
|
||||
}
|
||||
|
||||
if (response.skippedCount > 0) {
|
||||
summaries.push(getMCPAccessSkippedSummary(response.skippedCount, scopeName));
|
||||
}
|
||||
|
||||
if (summaries.length === 0) {
|
||||
return i18n.baseText('resourceActions.mcpAccess.noWorkflows.message', {
|
||||
interpolate: { link: settingsLink.value, scopeName },
|
||||
});
|
||||
}
|
||||
|
||||
return i18n.baseText('resourceActions.mcpAccess.outcome.message', {
|
||||
interpolate: { link: settingsLink.value, summary: `${summaries.join('. ')}.` },
|
||||
});
|
||||
}
|
||||
|
||||
function showMCPAccessOutcomeToast(
|
||||
enabled: boolean,
|
||||
response: ToggleWorkflowsMcpAccessResponse,
|
||||
scopeName: string,
|
||||
) {
|
||||
const hasUpdated = response.updatedCount > 0;
|
||||
const hasSkipped = response.skippedCount > 0;
|
||||
const hasUnchanged = response.unchangedCount > 0;
|
||||
let title = i18n.baseText('resourceActions.mcpAccess.noWorkflows.title');
|
||||
let type: 'info' | 'success' | 'warning' = 'info';
|
||||
|
||||
if (hasSkipped) {
|
||||
title = i18n.baseText('resourceActions.mcpAccess.partial.title');
|
||||
type = 'warning';
|
||||
} else if (hasUpdated) {
|
||||
title = enabled
|
||||
? i18n.baseText('resourceActions.mcpAccess.success.enabled.title')
|
||||
: i18n.baseText('resourceActions.mcpAccess.success.disabled.title');
|
||||
type = 'success';
|
||||
} else if (hasUnchanged) {
|
||||
title = i18n.baseText('resourceActions.mcpAccess.noChanges.title');
|
||||
}
|
||||
|
||||
toast.showToast({
|
||||
title,
|
||||
message: getMCPAccessOutcomeMessage(enabled, response, scopeName),
|
||||
onClick: openSettingsFromToast,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
function showMCPAccessErrorToast(enabled: boolean, scopeName: string) {
|
||||
const title = enabled
|
||||
? i18n.baseText('resourceActions.mcpAccess.error.enabled.title')
|
||||
: i18n.baseText('resourceActions.mcpAccess.error.disabled.title');
|
||||
const message = enabled
|
||||
? i18n.baseText('resourceActions.mcpAccess.error.enabled.message', {
|
||||
interpolate: {
|
||||
link: settingsLink.value,
|
||||
scopeName,
|
||||
},
|
||||
})
|
||||
: i18n.baseText('resourceActions.mcpAccess.error.disabled.message', {
|
||||
interpolate: {
|
||||
link: settingsLink.value,
|
||||
scopeName,
|
||||
},
|
||||
});
|
||||
|
||||
toast.showToast({
|
||||
title,
|
||||
message,
|
||||
onClick: openSettingsFromToast,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function showMCPAccessDisabledToast() {
|
||||
toast.showToast({
|
||||
title: i18n.baseText('resourceActions.mcpAccess.disabled.title'),
|
||||
message: i18n.baseText('resourceActions.mcpAccess.disabled.message', {
|
||||
interpolate: { link: settingsLink.value },
|
||||
}),
|
||||
onClick: openSettingsFromToast,
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleMcpAccess(
|
||||
enabled: boolean,
|
||||
scope: McpAccessScope | null = mcpAccessScope.value,
|
||||
) {
|
||||
if (!mcpEnabled.value) {
|
||||
showMCPAccessDisabledToast();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scope) return;
|
||||
|
||||
const target = getMcpAccessTarget(scope);
|
||||
if (!target) return;
|
||||
|
||||
try {
|
||||
const response = await mcpStore.toggleWorkflowsMcpAccess(target, enabled);
|
||||
await fetchWorkflows();
|
||||
|
||||
if (response.failedCount > 0) {
|
||||
showMCPAccessErrorToast(enabled, scope.name);
|
||||
return;
|
||||
}
|
||||
|
||||
showMCPAccessOutcomeToast(enabled, response, scope.name);
|
||||
} catch {
|
||||
showMCPAccessErrorToast(enabled, scope.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Folder card action handlers
|
||||
// These render on each folder card and are applied to the clicked folder
|
||||
const onFolderCardAction = async (payload: { action: string; folderId: string }) => {
|
||||
const clickedFolder = foldersStore.getCachedFolder(payload.folderId);
|
||||
if (!clickedFolder) return;
|
||||
switch (payload.action) {
|
||||
case MCP_ACCESS_ACTIONS.ENABLE:
|
||||
await toggleMcpAccess(true, {
|
||||
type: 'folder',
|
||||
id: clickedFolder.id,
|
||||
name: clickedFolder.name,
|
||||
});
|
||||
break;
|
||||
case MCP_ACCESS_ACTIONS.DISABLE:
|
||||
await toggleMcpAccess(false, {
|
||||
type: 'folder',
|
||||
id: clickedFolder.id,
|
||||
name: clickedFolder.name,
|
||||
});
|
||||
break;
|
||||
case FOLDER_LIST_ITEM_ACTIONS.CREATE:
|
||||
await createFolder(
|
||||
{
|
||||
|
|
@ -1316,6 +1641,9 @@ const createFolder = async (
|
|||
parent: { id: string; name: string; type: 'project' | 'folder' },
|
||||
options: { openAfterCreate: boolean } = { openAfterCreate: false },
|
||||
) => {
|
||||
const projectId = currentBreadcrumbsProject.value?.id;
|
||||
if (!projectId) return;
|
||||
|
||||
const promptResponsePromise = message.prompt(
|
||||
i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }),
|
||||
{
|
||||
|
|
@ -1331,13 +1659,13 @@ const createFolder = async (
|
|||
try {
|
||||
const newFolder = await foldersStore.createFolder(
|
||||
folderName,
|
||||
route.params.projectId as string,
|
||||
projectId,
|
||||
parent.type === 'folder' ? parent.id : undefined,
|
||||
);
|
||||
|
||||
const newFolderURL = router.resolve({
|
||||
name: VIEWS.PROJECTS_FOLDERS,
|
||||
params: { projectId: route.params.projectId, folderId: newFolder.id },
|
||||
params: { projectId, folderId: newFolder.id },
|
||||
}).href;
|
||||
toast.showToast({
|
||||
title: i18n.baseText('folders.add.success.title'),
|
||||
|
|
@ -1362,7 +1690,7 @@ const createFolder = async (
|
|||
// Navigate to parent folder id option specified by the caller
|
||||
await router.push({
|
||||
name: VIEWS.PROJECTS_FOLDERS,
|
||||
params: { projectId: route.params.projectId, folderId: parent.id },
|
||||
params: { projectId, folderId: parent.id },
|
||||
});
|
||||
} else {
|
||||
// If we are on an empty list, just add the new folder to the list
|
||||
|
|
@ -1374,7 +1702,7 @@ const createFolder = async (
|
|||
resource: 'folder',
|
||||
createdAt: newFolder.createdAt,
|
||||
updatedAt: newFolder.updatedAt,
|
||||
homeProject: projectsStore.currentProject as ProjectSharingData,
|
||||
homeProject: currentBreadcrumbsProject.value as ProjectSharingData,
|
||||
workflowCount: 0,
|
||||
subFolderCount: 0,
|
||||
},
|
||||
|
|
@ -1437,8 +1765,8 @@ const createFolderInCurrent = async () => {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (!route.params.projectId) return;
|
||||
const currentParent = currentFolder.value?.name || projectName.value;
|
||||
if (!currentBreadcrumbsProject.value) return;
|
||||
const currentParent = currentFolder.value?.name || currentBreadcrumbsProjectName.value;
|
||||
if (!currentParent) return;
|
||||
await createFolder({
|
||||
id: (route.params.folderId as string) ?? '-1',
|
||||
|
|
@ -1823,11 +2151,8 @@ const onNameSubmit = async (name: string) => {
|
|||
/>
|
||||
</ProjectHeader>
|
||||
</template>
|
||||
<template v-if="showFolders || showRegisteredCommunityCTA" #add-button>
|
||||
<N8nTooltip
|
||||
placement="top"
|
||||
:disabled="!showRegisteredCommunityCTA && (readOnlyEnv || !hasPermissionToCreateFolders)"
|
||||
>
|
||||
<template v-if="showRegisteredCommunityCTA" #add-button>
|
||||
<N8nTooltip placement="top">
|
||||
<template #content>
|
||||
<span>
|
||||
{{
|
||||
|
|
@ -1846,8 +2171,6 @@ const onNameSubmit = async (name: string) => {
|
|||
icon="folder-plus"
|
||||
:aria-label="i18n.baseText('workflows.addFolder')"
|
||||
data-test-id="add-folder-button"
|
||||
:class="$style['add-folder-button']"
|
||||
:disabled="!showRegisteredCommunityCTA && (readOnlyEnv || !hasPermissionToCreateFolders)"
|
||||
@click="createFolderInCurrent"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
|
|
@ -1924,13 +2247,13 @@ const onNameSubmit = async (name: string) => {
|
|||
<N8nLoading :loading="breadcrumbsLoading" :rows="1" variant="p" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showFolders && currentFolder"
|
||||
v-else-if="showMainBreadcrumbs"
|
||||
:class="$style['breadcrumbs-container']"
|
||||
data-test-id="main-breadcrumbs"
|
||||
>
|
||||
<FolderBreadcrumbs
|
||||
:current-folder="currentFolderParent"
|
||||
:actions="mainBreadcrumbsActions"
|
||||
:actions="breadcrumbsActions"
|
||||
:hidden-items-trigger="isDragging ? 'hover' : 'click'"
|
||||
:current-folder-as-link="true"
|
||||
@item-selected="onBreadcrumbItemClick"
|
||||
|
|
@ -1938,7 +2261,7 @@ const onNameSubmit = async (name: string) => {
|
|||
@item-drop="onBreadCrumbsItemDrop"
|
||||
@project-drop="moveFolderToProjectRoot"
|
||||
>
|
||||
<template #append>
|
||||
<template v-if="currentFolder" #append>
|
||||
<span :class="$style['path-separator']">/</span>
|
||||
<N8nInlineTextEdit
|
||||
ref="renameInput"
|
||||
|
|
@ -1991,6 +2314,7 @@ const onNameSubmit = async (name: string) => {
|
|||
foldersStore.activeDropTarget?.id === (data as FolderResource).id,
|
||||
}"
|
||||
:show-ownership-badge="showCardsBadge"
|
||||
:show-mcp-access-actions="showMcpAccessActions"
|
||||
data-target="folder"
|
||||
data-droppable
|
||||
class="mb-2xs"
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ export type ToggleWorkflowsMcpAccessTarget =
|
|||
|
||||
export type ToggleWorkflowsMcpAccessResponse = {
|
||||
updatedCount: number;
|
||||
unchangedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
updatedIds?: string[];
|
||||
unchangedIds?: string[];
|
||||
};
|
||||
|
||||
export async function getMcpSettings(context: IRestApiContext): Promise<McpSettingsResponse> {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ describe('mcp.store', () => {
|
|||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 1,
|
||||
updatedIds: ['wf-1'],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
@ -72,6 +74,8 @@ describe('mcp.store', () => {
|
|||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 1,
|
||||
updatedIds: ['wf-1'],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
@ -85,6 +89,8 @@ describe('mcp.store', () => {
|
|||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 1,
|
||||
updatedIds: ['wf-current'],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
@ -108,6 +114,8 @@ describe('mcp.store', () => {
|
|||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 0,
|
||||
updatedIds: [],
|
||||
unchangedCount: 0,
|
||||
unchangedIds: [],
|
||||
skippedCount: 1,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
@ -140,7 +148,7 @@ describe('mcp.store', () => {
|
|||
});
|
||||
|
||||
describe('toggleWorkflowsMcpAccess (bulk)', () => {
|
||||
it('applies the new value only to workflows the backend confirmed were updated', async () => {
|
||||
it('applies the new value only to workflows the backend confirmed', async () => {
|
||||
workflowsListStore.workflowsById = {
|
||||
'wf-1': {
|
||||
id: 'wf-1',
|
||||
|
|
@ -152,31 +160,42 @@ describe('mcp.store', () => {
|
|||
name: 'wf-2',
|
||||
settings: { availableInMCP: false, executionOrder: 'v1' },
|
||||
},
|
||||
'wf-3': {
|
||||
id: 'wf-3',
|
||||
name: 'wf-3',
|
||||
settings: { availableInMCP: false, executionOrder: 'v1' },
|
||||
},
|
||||
} as unknown as typeof workflowsListStore.workflowsById;
|
||||
|
||||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 1,
|
||||
updatedIds: ['wf-1'],
|
||||
unchangedCount: 1,
|
||||
unchangedIds: ['wf-2'],
|
||||
skippedCount: 1,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
||||
const response = await store.toggleWorkflowsMcpAccess(
|
||||
{ workflowIds: ['wf-1', 'wf-2'] },
|
||||
{ workflowIds: ['wf-1', 'wf-2', 'wf-3'] },
|
||||
true,
|
||||
);
|
||||
|
||||
expect(response.updatedIds).toEqual(['wf-1']);
|
||||
expect(response.unchangedIds).toEqual(['wf-2']);
|
||||
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(true);
|
||||
expect(workflowsListStore.workflowsById['wf-2'].settings?.availableInMCP).toBe(true);
|
||||
// Skipped workflow remains untouched.
|
||||
expect(workflowsListStore.workflowsById['wf-2'].settings?.availableInMCP).toBe(false);
|
||||
expect(workflowsListStore.workflowsById['wf-3'].settings?.availableInMCP).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw when none of the targeted workflows were updated', async () => {
|
||||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 0,
|
||||
updatedIds: [],
|
||||
skippedCount: 2,
|
||||
unchangedCount: 2,
|
||||
unchangedIds: ['wf-1', 'wf-2'],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
||||
|
|
@ -185,7 +204,9 @@ describe('mcp.store', () => {
|
|||
).resolves.toEqual({
|
||||
updatedCount: 0,
|
||||
updatedIds: [],
|
||||
skippedCount: 2,
|
||||
unchangedCount: 2,
|
||||
unchangedIds: ['wf-1', 'wf-2'],
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
});
|
||||
|
|
@ -194,6 +215,7 @@ describe('mcp.store', () => {
|
|||
// Project-scoped responses from the backend omit `updatedIds`.
|
||||
const spy = vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
@ -207,6 +229,7 @@ describe('mcp.store', () => {
|
|||
// Folder-scoped responses from the backend omit `updatedIds`.
|
||||
const spy = vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 0,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
|
@ -227,13 +250,14 @@ describe('mcp.store', () => {
|
|||
|
||||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 5,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.toggleWorkflowsMcpAccess({ projectId: 'project-1' }, true),
|
||||
).resolves.toEqual({ updatedCount: 5, skippedCount: 0, failedCount: 0 });
|
||||
).resolves.toEqual({ updatedCount: 5, unchangedCount: 0, skippedCount: 0, failedCount: 0 });
|
||||
|
||||
expect(workflowsListStore.workflowsById['wf-1'].settings?.availableInMCP).toBe(false);
|
||||
});
|
||||
|
|
@ -241,13 +265,19 @@ describe('mcp.store', () => {
|
|||
it('surfaces partial failures from the backend via failedCount', async () => {
|
||||
vi.spyOn(mcpApi, 'toggleWorkflowsMcpAccessApi').mockResolvedValue({
|
||||
updatedCount: 500,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 100,
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.toggleWorkflowsMcpAccess({ projectId: 'big-project' }, true),
|
||||
).resolves.toEqual({ updatedCount: 500, skippedCount: 0, failedCount: 100 });
|
||||
).resolves.toEqual({
|
||||
updatedCount: 500,
|
||||
unchangedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,7 +97,12 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
|||
availableInMCP,
|
||||
);
|
||||
|
||||
if (!(response.updatedIds ?? []).includes(workflowId)) {
|
||||
const confirmedIds = new Set([
|
||||
...(response.updatedIds ?? []),
|
||||
...(response.unchangedIds ?? []),
|
||||
]);
|
||||
|
||||
if (!confirmedIds.has(workflowId)) {
|
||||
throw new Error(
|
||||
i18n.baseText('workflowSettings.toggleMCP.updateSkippedError', {
|
||||
interpolate: { workflowId },
|
||||
|
|
@ -124,7 +129,12 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
|||
availableInMCP,
|
||||
);
|
||||
|
||||
for (const id of response.updatedIds ?? []) {
|
||||
const confirmedIds = new Set([
|
||||
...(response.updatedIds ?? []),
|
||||
...(response.unchangedIds ?? []),
|
||||
]);
|
||||
|
||||
for (const id of confirmedIds) {
|
||||
applyAvailableInMCPToLocalStores(id, availableInMCP);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,28 @@ import { useFoldersStore } from '../folders.store';
|
|||
import type { FolderPathItem, FolderShortInfo } from '../folders.types';
|
||||
import type { IUser } from 'n8n-workflow';
|
||||
import ProjectBreadcrumb from '@/features/core/folders/components/ProjectBreadcrumb.vue';
|
||||
import {
|
||||
N8nBreadcrumbs,
|
||||
N8nDropdownMenu,
|
||||
N8nIconButton,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
type DropdownMenuItemProps,
|
||||
} from '@n8n/design-system';
|
||||
|
||||
type FolderBreadcrumbAction = UserAction<IUser> & {
|
||||
children?: FolderBreadcrumbAction[];
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
type MenuItemData = {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
import { N8nActionToggle, N8nBreadcrumbs } from '@n8n/design-system';
|
||||
type Props = {
|
||||
// Current folder can be null when showing breadcrumbs for workflows in project root
|
||||
currentFolder?: FolderShortInfo | null;
|
||||
actions?: Array<UserAction<IUser>>;
|
||||
actions?: FolderBreadcrumbAction[];
|
||||
hiddenItemsTrigger?: 'hover' | 'click';
|
||||
currentFolderAsLink?: boolean;
|
||||
visibleLevels?: 1 | 2;
|
||||
|
|
@ -77,6 +93,21 @@ const hasMoreItems = computed(() => {
|
|||
return visibleBreadcrumbsItems.value[0]?.parentFolder !== undefined;
|
||||
});
|
||||
|
||||
function toMenuItem(action: FolderBreadcrumbAction): DropdownMenuItemProps<string, MenuItemData> {
|
||||
return {
|
||||
id: action.value,
|
||||
testId: `action-${action.value}`,
|
||||
label: action.label,
|
||||
disabled: action.disabled,
|
||||
children: action.children?.map(toMenuItem),
|
||||
data: action.tooltip ? { tooltip: action.tooltip } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const menuItems = computed<Array<DropdownMenuItemProps<string, MenuItemData>>>(() =>
|
||||
props.actions.map(toMenuItem),
|
||||
);
|
||||
|
||||
const visibleBreadcrumbsItems = computed<FolderPathItem[]>(() => {
|
||||
visibleIds.value.clear();
|
||||
if (!props.currentFolder || isSharedContext.value) return [];
|
||||
|
|
@ -233,14 +264,49 @@ onBeforeUnmount(() => {
|
|||
<div v-else>
|
||||
<slot name="append"></slot>
|
||||
</div>
|
||||
<N8nActionToggle
|
||||
v-if="visibleBreadcrumbsItems && actions?.length"
|
||||
:actions="actions"
|
||||
:class="$style['action-toggle']"
|
||||
theme="dark"
|
||||
data-test-id="folder-breadcrumbs-actions"
|
||||
@action="onAction"
|
||||
/>
|
||||
<N8nDropdownMenu
|
||||
v-if="menuItems.length"
|
||||
:items="menuItems"
|
||||
content-test-id="action-toggle-dropdown"
|
||||
placement="bottom-end"
|
||||
:extra-popper-class="$style['actions-menu-dropdown']"
|
||||
@select="onAction"
|
||||
>
|
||||
<template #trigger>
|
||||
<N8nIconButton
|
||||
:class="['action-toggle', $style['actions-menu']]"
|
||||
variant="ghost"
|
||||
icon="ellipsis-vertical"
|
||||
size="medium"
|
||||
data-test-id="folder-breadcrumbs-actions"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ item, ui }">
|
||||
<N8nTooltip
|
||||
v-if="item.data?.tooltip"
|
||||
:content="item.data.tooltip"
|
||||
placement="left"
|
||||
:show-after="300"
|
||||
:teleported="false"
|
||||
>
|
||||
<N8nText
|
||||
:class="ui.class"
|
||||
size="medium"
|
||||
:color="item.disabled ? 'text-light' : 'text-dark'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</N8nText>
|
||||
</N8nTooltip>
|
||||
<N8nText
|
||||
v-else
|
||||
:class="ui.class"
|
||||
size="medium"
|
||||
:color="item.disabled ? 'text-light' : 'text-dark'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</N8nText>
|
||||
</template>
|
||||
</N8nDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -255,9 +321,7 @@ onBeforeUnmount(() => {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.action-toggle {
|
||||
span[role='button'] {
|
||||
color: var(--color--text);
|
||||
}
|
||||
.actions-menu-dropdown {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { FOLDER_LIST_ITEM_ACTIONS } from '../folders.constants';
|
||||
import { computed, getCurrentInstance, ref } from 'vue';
|
||||
import { FOLDER_LIST_ITEM_ACTIONS, MCP_ACCESS_ACTIONS } from '../folders.constants';
|
||||
import { ProjectTypes, type Project } from '@/features/collaboration/projects/projects.types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
|
@ -15,27 +15,41 @@ import TimeAgo from '@/app/components/TimeAgo.vue';
|
|||
import ProjectCardBadge from '@/features/collaboration/projects/components/ProjectCardBadge.vue';
|
||||
|
||||
import {
|
||||
N8nActionToggle,
|
||||
N8nBadge,
|
||||
N8nBreadcrumbs,
|
||||
N8nCard,
|
||||
N8nDropdownMenu,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
type DropdownMenuItemProps,
|
||||
} from '@n8n/design-system';
|
||||
|
||||
type FolderCardAction = UserAction<IUser> & {
|
||||
children?: FolderCardAction[];
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
type MenuItemData = {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: FolderResource;
|
||||
personalProject: Project | null;
|
||||
actions?: Array<UserAction<IUser>>;
|
||||
actions?: FolderCardAction[];
|
||||
readOnly?: boolean;
|
||||
showOwnershipBadge?: boolean;
|
||||
showMcpAccessActions?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actions: () => [],
|
||||
readOnly: true,
|
||||
showOwnershipBadge: false,
|
||||
showMcpAccessActions: false,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -49,14 +63,40 @@ const emit = defineEmits<{
|
|||
folderOpened: [{ folder: FolderResource }];
|
||||
}>();
|
||||
|
||||
const dropdownId = `folder-card-actions-dropdown-${getCurrentInstance()?.uid ?? 0}`;
|
||||
|
||||
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
|
||||
|
||||
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
|
||||
|
||||
const resourceTypeLabel = computed(() => i18n.baseText('generic.folder').toLowerCase());
|
||||
|
||||
const allActions = computed<Array<UserAction<IUser>>>(() => {
|
||||
const favoriteAction = {
|
||||
const mcpAccessAction = computed<FolderCardAction>(() => ({
|
||||
label: i18n.baseText('resourceActions.mcpAccess.manage'),
|
||||
value: MCP_ACCESS_ACTIONS.MANAGE,
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
label: i18n.baseText('resourceActions.mcpAccess.enable'),
|
||||
value: MCP_ACCESS_ACTIONS.ENABLE,
|
||||
disabled: false,
|
||||
tooltip: i18n.baseText('resourceActions.mcpAccess.enable.tooltip', {
|
||||
interpolate: { scopeName: props.data.name },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('resourceActions.mcpAccess.disable'),
|
||||
value: MCP_ACCESS_ACTIONS.DISABLE,
|
||||
disabled: false,
|
||||
tooltip: i18n.baseText('resourceActions.mcpAccess.disable.tooltip', {
|
||||
interpolate: { scopeName: props.data.name },
|
||||
}),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const allActions = computed<FolderCardAction[]>(() => {
|
||||
const favoriteAction: FolderCardAction = {
|
||||
label: favoritesStore.isFavorite(props.data.id, 'folder')
|
||||
? i18n.baseText('favorites.remove')
|
||||
: i18n.baseText('favorites.add'),
|
||||
|
|
@ -64,14 +104,33 @@ const allActions = computed<Array<UserAction<IUser>>>(() => {
|
|||
disabled: false,
|
||||
};
|
||||
const renameIndex = props.actions.findIndex((a) => a.value === FOLDER_LIST_ITEM_ACTIONS.RENAME);
|
||||
let result: FolderCardAction[];
|
||||
|
||||
if (renameIndex !== -1) {
|
||||
const result = [...props.actions];
|
||||
result = [...props.actions];
|
||||
result.splice(renameIndex, 0, favoriteAction);
|
||||
return result;
|
||||
} else {
|
||||
result = [...props.actions, favoriteAction];
|
||||
}
|
||||
return [...props.actions, favoriteAction];
|
||||
|
||||
return props.showMcpAccessActions ? [...result, mcpAccessAction.value] : result;
|
||||
});
|
||||
|
||||
function toMenuItem(action: FolderCardAction): DropdownMenuItemProps<string, MenuItemData> {
|
||||
return {
|
||||
id: action.value,
|
||||
testId: `action-${action.value}`,
|
||||
label: action.label,
|
||||
disabled: action.disabled,
|
||||
children: action.children?.map(toMenuItem),
|
||||
data: action.tooltip ? { tooltip: action.tooltip } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const menuItems = computed<Array<DropdownMenuItemProps<string, MenuItemData>>>(() =>
|
||||
allActions.value.map(toMenuItem),
|
||||
);
|
||||
|
||||
const cardUrl = computed(() => {
|
||||
return getFolderUrl(props.data.id);
|
||||
});
|
||||
|
|
@ -259,12 +318,51 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
|
|||
</div>
|
||||
</ProjectCardBadge>
|
||||
</div>
|
||||
<N8nActionToggle
|
||||
:actions="allActions"
|
||||
theme="dark"
|
||||
data-test-id="folder-card-actions"
|
||||
@action="onAction"
|
||||
/>
|
||||
<span data-test-id="folder-card-actions">
|
||||
<N8nDropdownMenu
|
||||
:id="dropdownId"
|
||||
:items="menuItems"
|
||||
placement="bottom-end"
|
||||
:extra-popper-class="$style['actions-menu-dropdown']"
|
||||
@select="onAction"
|
||||
>
|
||||
<template #trigger>
|
||||
<N8nIconButton
|
||||
:class="['action-toggle', $style['actions-menu']]"
|
||||
variant="ghost"
|
||||
icon="ellipsis-vertical"
|
||||
size="medium"
|
||||
role="button"
|
||||
:aria-controls="dropdownId"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ item, ui }">
|
||||
<N8nTooltip
|
||||
v-if="item.data?.tooltip"
|
||||
:content="item.data.tooltip"
|
||||
placement="left"
|
||||
:show-after="300"
|
||||
:teleported="false"
|
||||
>
|
||||
<N8nText
|
||||
:class="ui.class"
|
||||
size="medium"
|
||||
:color="item.disabled ? 'text-light' : 'text-dark'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</N8nText>
|
||||
</N8nTooltip>
|
||||
<N8nText
|
||||
v-else
|
||||
:class="ui.class"
|
||||
size="medium"
|
||||
:color="item.disabled ? 'text-light' : 'text-dark'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</N8nText>
|
||||
</template>
|
||||
</N8nDropdownMenu>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</N8nCard>
|
||||
|
|
@ -326,6 +424,10 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
|
|||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.actions-menu-dropdown {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
.card {
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ type Props = {
|
|||
currentProject?: Project;
|
||||
isDragging?: boolean;
|
||||
isShared?: boolean;
|
||||
icon?: IconOrEmoji;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentProject: undefined,
|
||||
isDragging: false,
|
||||
isShared: false,
|
||||
icon: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -26,6 +28,10 @@ const emit = defineEmits<{
|
|||
const i18n = useI18n();
|
||||
|
||||
const projectIcon = computed((): IconOrEmoji => {
|
||||
if (props.icon) {
|
||||
return props.icon;
|
||||
}
|
||||
|
||||
if (props.isShared) {
|
||||
return { type: 'icon', value: 'share' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,3 +35,9 @@ export const FOLDER_LIST_ITEM_ACTIONS = {
|
|||
DELETE: 'delete',
|
||||
TOGGLE_FAVORITE: 'toggleFavorite',
|
||||
};
|
||||
|
||||
export const MCP_ACCESS_ACTIONS = {
|
||||
MANAGE: 'manageMcpAccess',
|
||||
ENABLE: 'enableMcpAccess',
|
||||
DISABLE: 'disableMcpAccess',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -125,10 +125,6 @@ export class WorkflowsPage extends BasePage {
|
|||
return this.getFolderBreadcrumbsActionToggle().getByTestId(`action-${actionName}`);
|
||||
}
|
||||
|
||||
addFolderButton() {
|
||||
return this.page.getByTestId('add-folder-button');
|
||||
}
|
||||
|
||||
// Add region for actions
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -40,16 +40,6 @@ test.describe(
|
|||
await expect(n8n.workflows.cards.getFolder(childFolderName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a folder from the list header button', async ({ n8n }) => {
|
||||
const projectId = await n8n.start.fromNewProject();
|
||||
await n8n.api.projects.createFolder(projectId);
|
||||
await n8n.workflows.addFolderButton().click();
|
||||
const childFolderName = 'My Child Folder';
|
||||
await n8n.workflows.fillFolderModal(childFolderName);
|
||||
|
||||
await expect(n8n.workflows.cards.getFolder(childFolderName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a folder from the card dropdown', async ({ n8n }) => {
|
||||
const projectId = await n8n.start.fromNewProject();
|
||||
const folder = await n8n.api.projects.createFolder(projectId);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user