feat(editor): Introduce new project/folder actions menu (#30614)

This commit is contained in:
Milorad FIlipović 2026-05-27 20:36:37 +02:00 committed by GitHub
parent e9649d21fd
commit f0ea4ed1f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 728 additions and 118 deletions

View File

@ -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);
});

View File

@ -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,
});

View File

@ -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: [],
});
});

View File

@ -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 } : {}),
};
}

View File

@ -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",

View File

@ -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,
});

View File

@ -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);

View File

@ -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"

View File

@ -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> {

View File

@ -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,
});
});
});
});

View File

@ -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);
}

View File

@ -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>

View File

@ -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;

View File

@ -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' };
}

View File

@ -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;

View File

@ -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
/**

View File

@ -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);