From 1c2e89fa52c831ba08d95495d58cb0df79c71189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Thu, 11 Dec 2025 09:04:16 +0100 Subject: [PATCH] feat(editor): Edit workflow descriptions from the MCP page (#22967) --- .../frontend/@n8n/i18n/src/locales/en.json | 3 +- .../components/WorkflowDescriptionModal.vue | 4 ++ .../features/ai/mcpAccess/SettingsMCPView.vue | 23 +++++++ .../components/tabs/WorkflowsTable.test.ts | 66 +++++++++++++++++++ .../components/tabs/WorkflowsTable.vue | 20 +++++- 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 59442241f51..875882bc3fa 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2381,7 +2381,8 @@ "settings.mcp.tabs.workflows": "Workflows", "settings.mcp.tabs.oauth": "Connected clients", "settings.mcp.access.token.notice": "Make sure to copy your access token, you won't be able to see it again", - "settings.mcp.workflows.table.action.removeMCPAccess": "Remove Access", + "settings.mcp.workflows.table.action.removeMCPAccess": "Remove access", + "settings.mcp.workflows.table.action.updateDescription": "Edit description", "settings.mcp.workflows.table.column.name": "Name", "settings.mcp.workflows.table.column.location": "Location", "settings.mcp.workflows.table.column.description.emptyContent": "No description", diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.vue b/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.vue index 8023463c5c3..75d843d6558 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowDescriptionModal.vue @@ -16,6 +16,7 @@ const props = defineProps<{ data: { workflowId: string; workflowDescription?: string | null; + onSave?: (description: string | null) => void; }; }>(); @@ -57,6 +58,9 @@ const saveDescription = async () => { props.data.workflowId, normalizedCurrentValue.value ?? null, ); + + props.data.onSave?.(normalizedCurrentValue.value ?? null); + telemetry.track('User set workflow description', { workflow_id: props.data.workflowId, description: normalizedCurrentValue.value ?? null, diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue index 617d8fc3a92..c1bd318edc2 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue @@ -7,6 +7,7 @@ import { useI18n } from '@n8n/i18n'; import { computed, onMounted, ref } from 'vue'; import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store'; import { useUsersStore } from '@/features/settings/users/users.store'; +import { useUIStore } from '@/app/stores/ui.store'; import { LOADING_INDICATOR_TIMEOUT } from '@/features/ai/mcpAccess/mcp.constants'; import MCPEmptyState from '@/features/ai/mcpAccess/components/MCPEmptyState.vue'; import MCpHeaderActions from '@/features/ai/mcpAccess/components/header/MCPHeaderActions.vue'; @@ -16,6 +17,7 @@ import { N8nHeading, N8nTabs, N8nTooltip, N8nButton } from '@n8n/design-system'; import type { TabOptions } from '@n8n/design-system'; import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; import type { OAuthClientResponseDto } from '@n8n/api-types'; +import { WORKFLOW_DESCRIPTION_MODAL_KEY } from '@/app/constants'; type MCPTabs = 'workflows' | 'oauth'; @@ -27,6 +29,7 @@ const mcp = useMcp(); const workflowsStore = useWorkflowsStore(); const mcpStore = useMCPStore(); const usersStore = useUsersStore(); +const uiStore = useUIStore(); const mcpStatusLoading = ref(false); const selectedTab = ref('workflows'); @@ -90,6 +93,25 @@ const onRemoveMCPAccess = async (workflow: WorkflowListItem) => { } }; +const onUpdateDescription = (workflow: WorkflowListItem) => { + uiStore.openModalWithData({ + name: WORKFLOW_DESCRIPTION_MODAL_KEY, + data: { + workflowId: workflow.id, + workflowDescription: workflow.description ?? '', + onSave: (updatedDescription: string | null) => { + const index = availableWorkflows.value.findIndex((w) => w.id === workflow.id); + if (index !== -1) { + availableWorkflows.value[index] = { + ...availableWorkflows.value[index], + description: updatedDescription ?? undefined, + }; + } + }, + }, + }); +}; + const onTableRefresh = async () => { if (selectedTab.value === 'workflows') { await fetchAvailableWorkflows(); @@ -196,6 +218,7 @@ const revokeClientAccess = async (client: OAuthClientResponseDto) => { :workflows="availableWorkflows" :loading="workflowsLoading" @remove-mcp-access="onRemoveMCPAccess" + @update-description="onUpdateDescription" @refresh="onRefreshWorkflows" /> { expect(emitted('removeMcpAccess')).toBeTruthy(); expect(emitted('removeMcpAccess')[0]).toEqual([workflow]); }); + + it('should enable "Update Description" action when user has update permissions', async () => { + const workflow = createWorkflow({ + scopes: ['workflow:read', 'workflow:update'], + }); + + const { getByTestId } = createComponent({ + props: { + workflows: [workflow], + loading: false, + }, + }); + + const actionToggle = getByTestId('mcp-workflow-action-toggle'); + const toggleButton = within(actionToggle).getByRole('button'); + await userEvent.click(toggleButton); + + const menuItems = document.querySelectorAll('[data-test-id="action-updateDescription"]'); + expect(menuItems.length).toBe(1); + expect(menuItems[0]).not.toHaveAttribute('disabled'); + }); + + it('should disable "Update Description" action when user lacks update permissions', async () => { + const workflow = createWorkflow({ + scopes: ['workflow:read'], + }); + + const { getByTestId } = createComponent({ + props: { + workflows: [workflow], + loading: false, + }, + }); + + const actionToggle = getByTestId('mcp-workflow-action-toggle'); + const toggleButton = within(actionToggle).getByRole('button'); + await userEvent.click(toggleButton); + + const menuItems = document.querySelectorAll('[data-test-id="action-updateDescription"]'); + expect(menuItems.length).toBe(1); + expect(menuItems[0]).toHaveClass('is-disabled'); + }); + + it('should emit updateDescription event when action is clicked', async () => { + const workflow = createWorkflow({ + scopes: ['workflow:read', 'workflow:update'], + }); + + const { getByTestId, emitted } = createComponent({ + props: { + workflows: [workflow], + loading: false, + }, + }); + + const actionToggle = getByTestId('mcp-workflow-action-toggle'); + const toggleButton = within(actionToggle).getByRole('button'); + await userEvent.click(toggleButton); + + const menuItem = document.querySelector('[data-test-id="action-updateDescription"]'); + expect(menuItem).not.toBeNull(); + await userEvent.click(menuItem!); + + expect(emitted('updateDescription')).toBeTruthy(); + expect(emitted('updateDescription')[0]).toEqual([workflow]); + }); }); describe('Workflow links', () => { diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue index a14ceb2c3ed..324cf035f53 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue @@ -27,6 +27,7 @@ const props = defineProps(); const emit = defineEmits<{ removeMcpAccess: [workflow: WorkflowListItem]; + updateDescription: [workflow: WorkflowListItem]; }>(); const i18n = useI18n(); @@ -80,6 +81,11 @@ const getAvailableActions = (workflow: WorkflowListItem): Array { case 'removeFromMCP': emit('removeMcpAccess', workflow); break; + case 'updateDescription': + emit('updateDescription', workflow); + break; default: break; } @@ -224,7 +233,11 @@ const navigateToWorkflowList = () => { :show-after="MCP_TOOLTIP_DELAY" :popper-class="$style['description-popper']" > -
+
{{ item.description }} @@ -319,6 +332,11 @@ const navigateToWorkflowList = () => { overflow: hidden; color: var(--color--text); padding: var(--spacing--2xs) 0; + cursor: pointer; + + &:hover span { + color: var(--color--text--shade-1); + } span { display: flex;