feat(editor): Move workflow description edit button to settings (#22301)

This commit is contained in:
Charlie Kolb
2025-12-05 12:20:27 +01:00
committed by GitHub
parent c43543fb84
commit 492aca09ff
12 changed files with 703 additions and 895 deletions

View File

@@ -1176,9 +1176,8 @@
"folder.delete.modal.confirmation": "What should we do with {folders} {workflows} in this folder?",
"folder.count": "the {count} folder | the {count} folders",
"workflow.count": "the {count} workflow | the {count} workflows",
"workflow.description.tooltip": "Edit workflow description",
"workflow.description.placeholder": "Describe the purpose and functionality of this workflow",
"workflow.description.placeholder.mcp": "To help MCP clients understand when to use this workflow, add a short workflow description that describes what it does.",
"workflow.description.mcp": "Clear descriptions help other users and MCP clients understand the purpose of your workflow",
"workflow.description.nomcp": "Clear descriptions help other users understand the purpose of your workflow",
"workflow.description.error.title": "Problem updating workflow description",
"folder.and.workflow.separator": "and",
"folders.delete.action": "Archive all workflows and delete subfolders",
@@ -1312,9 +1311,10 @@
"mcp.workflowDeactivated.message": "MCP Access has been disabled for this workflow because it is deactivated",
"menuActions.duplicate": "Duplicate",
"menuActions.download": "Download",
"menuActions.push": "Push to Git",
"menuActions.push": "Push to git",
"menuActions.editDescription": "Edit description",
"menuActions.importFromUrl": "Import from URL...",
"menuActions.importFromFile": "Import from File...",
"menuActions.importFromFile": "Import from file...",
"menuActions.delete": "Delete",
"menuActions.archive": "Archive",
"menuActions.unarchive": "Unarchive",

View File

@@ -15,6 +15,7 @@ import {
IS_DRAFT_PUBLISH_ENABLED,
WORKFLOW_SHARE_MODAL_KEY,
EnterpriseEditionFeature,
WORKFLOW_DESCRIPTION_MODAL_KEY,
} from '@/app/constants';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { useRoute } from 'vue-router';
@@ -163,6 +164,11 @@ const workflowMenuItems = computed<Array<ActionDropdownItem<WORKFLOW_MENU_ACTION
label: locale.baseText('menuActions.duplicate'),
disabled: !onWorkflowPage.value || !props.id,
});
actions.unshift({
id: WORKFLOW_MENU_ACTIONS.EDIT_DESCRIPTION,
label: locale.baseText('menuActions.editDescription'),
disabled: !onWorkflowPage.value || !props.id,
});
actions.push(
{
@@ -239,6 +245,20 @@ const workflowMenuItems = computed<Array<ActionDropdownItem<WORKFLOW_MENU_ACTION
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
switch (action) {
case WORKFLOW_MENU_ACTIONS.EDIT_DESCRIPTION: {
const workflowId = getWorkflowId(props.id, route.params.name);
if (!workflowId) return;
const workflowDescription = workflowsStore.getWorkflowById(workflowId).description;
uiStore.openModalWithData({
name: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId,
workflowDescription,
},
});
break;
}
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
uiStore.openModalWithData({
name: DUPLICATE_MODAL_KEY,

View File

@@ -1,638 +0,0 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { nextTick } from 'vue';
import WorkflowDescriptionPopover from '@/app/components/MainHeader/WorkflowDescriptionPopover.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { STORES } from '@n8n/stores';
vi.mock('@/app/composables/useToast', () => {
const showError = vi.fn();
return {
useToast: () => ({
showError,
}),
};
});
vi.mock('@/app/composables/useTelemetry', () => {
const track = vi.fn();
return {
useTelemetry: () => ({
track,
}),
};
});
const initialState = {
[STORES.SETTINGS]: {
settings: {
modules: {
mcp: {
enabled: false,
},
},
},
},
};
const renderComponent = createComponentRenderer(WorkflowDescriptionPopover, {
pinia: createTestingPinia({ initialState }),
});
describe('WorkflowDescriptionPopover', () => {
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let uiStore: MockedStore<typeof useUIStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let telemetry: ReturnType<typeof useTelemetry>;
let toast: ReturnType<typeof useToast>;
beforeEach(() => {
workflowsStore = mockedStore(useWorkflowsStore);
uiStore = mockedStore(useUIStore);
settingsStore = mockedStore(useSettingsStore);
telemetry = useTelemetry();
toast = useToast();
// Reset mocks
workflowsStore.saveWorkflowDescription = vi.fn().mockResolvedValue(undefined);
workflowsStore.workflow = {
id: 'test-workflow-id',
name: 'Test Workflow',
active: false,
activeVersionId: null,
isArchived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
versionId: '1',
nodes: [],
connections: {},
};
uiStore.stateIsDirty = false;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Component rendering', () => {
it('should render the description button and default description', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
const button = getByTestId('workflow-description-button');
await userEvent.click(button);
const textarea = getByTestId('workflow-description-input');
expect(textarea).toHaveValue('Initial description');
});
it('should render empty string if there is no description', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
},
});
const button = getByTestId('workflow-description-button');
await userEvent.click(button);
const textarea = getByTestId('workflow-description-input');
expect(textarea).toHaveValue('');
});
});
describe('Popover interaction', () => {
it('should open popover when button is clicked', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Test description',
},
});
const button = getByTestId('workflow-description-button');
expect(queryByTestId('workflow-description-edit-content')).not.toBeInTheDocument();
await userEvent.click(button);
expect(getByTestId('workflow-description-edit-content')).toBeInTheDocument();
});
it('should focus textarea when popover opens', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
},
});
const button = getByTestId('workflow-description-button');
await userEvent.click(button);
await nextTick();
const textarea = getByTestId('workflow-description-input');
expect(textarea).toHaveFocus();
});
it('should save description when popover closes', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
const button = getByTestId('workflow-description-button');
await userEvent.click(button);
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'Updated description');
// Click outside to close popover
await userEvent.click(document.body);
expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith(
'test-workflow-id',
'Updated description',
);
});
});
describe('Save and Cancel functionality', () => {
it('should save description when save button is clicked', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'New description');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith(
'test-workflow-id',
'New description',
);
expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', {
workflow_id: 'test-workflow-id',
description: 'New description',
});
});
it('should save empty string when description is cleared', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith('test-workflow-id', '');
expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', {
workflow_id: 'test-workflow-id',
description: '',
});
});
it('should disable save button when description has not changed', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const saveButton = getByTestId('workflow-description-save-button');
expect(saveButton).toBeDisabled();
});
it('should disable save button when whitespace-only changes result in same trimmed value', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
// Type only whitespace
await userEvent.type(textarea, ' ');
const saveButton = getByTestId('workflow-description-save-button');
// Should be disabled since trimmed value is still empty
expect(saveButton).toBeDisabled();
});
it('should not save on Enter key when only whitespace is entered', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
// Type only whitespace
await userEvent.type(textarea, ' ');
await userEvent.keyboard('{Enter}');
// Should not save since canSave is false
expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled();
});
it('should enable save button when description changes', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
expect(saveButton).not.toBeDisabled();
});
it('should revert changes when cancel button is clicked', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'Changed description');
const cancelButton = getByTestId('workflow-description-cancel-button');
await userEvent.click(cancelButton);
// Re-open popover to check value
await userEvent.click(getByTestId('workflow-description-button'));
const textareaAfterCancel = getByTestId('workflow-description-input');
expect(textareaAfterCancel).toHaveValue('Initial description');
});
it('should disable cancel button during save', async () => {
workflowsStore.saveWorkflowDescription = vi.fn(
async () => await new Promise((resolve) => setTimeout(resolve, 100)),
);
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
const cancelButton = getByTestId('workflow-description-cancel-button');
await userEvent.click(saveButton);
// During save, cancel should be disabled
expect(cancelButton).toBeDisabled();
});
});
describe('Keyboard shortcuts', () => {
it('should save when Enter key is pressed', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'New description');
await userEvent.keyboard('{Enter}');
expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith(
'test-workflow-id',
'New description',
);
});
it('should allow new lines with Shift+Enter', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, 'Line 1');
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await userEvent.type(textarea, 'Line 2');
expect(textarea).toHaveValue('Line 1\nLine 2');
expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled();
});
it('should cancel when Escape key is pressed', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'Changed description');
await userEvent.keyboard('{Escape}');
// Check that popover is closed
expect(queryByTestId('workflow-description-edit-content')).not.toBeInTheDocument();
// Re-open to verify changes were reverted
await userEvent.click(getByTestId('workflow-description-button'));
const textareaAfterEscape = getByTestId('workflow-description-input');
expect(textareaAfterEscape).toHaveValue('Initial description');
});
});
describe('Error handling', () => {
it('should show error toast when save fails', async () => {
const error = new Error('Save failed');
workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error);
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
await vi.waitFor(() => {
expect(toast.showError).toHaveBeenCalledWith(
error,
'Problem updating workflow description',
);
});
});
it('should revert to last saved value on error', async () => {
const error = new Error('Save failed');
workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error);
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'Failed update');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
await vi.waitFor(() => {
expect(textarea).toHaveValue('Initial description');
});
});
});
describe('Dirty state management', () => {
it('should set dirty flag when description changes', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
expect(uiStore.stateIsDirty).toBe(false);
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
expect(uiStore.stateIsDirty).toBe(true);
});
it('should clear dirty flag when saving', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
expect(uiStore.stateIsDirty).toBe(true);
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
await vi.waitFor(() => {
expect(uiStore.stateIsDirty).toBe(false);
});
});
it('should clear dirty flag when canceling', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
expect(uiStore.stateIsDirty).toBe(true);
const cancelButton = getByTestId('workflow-description-cancel-button');
await userEvent.click(cancelButton);
expect(uiStore.stateIsDirty).toBe(false);
});
it('should handle whitespace-only changes correctly', async () => {
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: ' Initial ',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'Initial');
// Should not be dirty since trimmed values are the same
expect(uiStore.stateIsDirty).toBe(false);
});
});
describe('MCP tooltips', () => {
it('should show base tooltip when MCP is disabled', async () => {
// Ensure MCP is disabled
settingsStore.isModuleActive = vi.fn().mockReturnValue(false);
settingsStore.moduleSettings.mcp = { mcpAccessEnabled: false };
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
// The tooltip text appears as placeholder in the textarea
const textarea = getByTestId('workflow-description-input');
const placeholder = textarea.getAttribute('placeholder');
expect(placeholder).toContain('Edit workflow description');
expect(placeholder).not.toContain('MCP clients');
});
it('should show MCP tooltip when MCP is enabled', async () => {
// Enable MCP module
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
settingsStore.moduleSettings.mcp = { mcpAccessEnabled: true };
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
const placeholder = textarea.getAttribute('placeholder');
expect(placeholder).toContain('MCP clients');
expect(placeholder).not.toContain('Edit workflow description');
});
});
describe('UI state tracking', () => {
it('should track active actions during save', async () => {
const addActiveActionSpy = vi.spyOn(uiStore, 'addActiveAction');
const removeActiveActionSpy = vi.spyOn(uiStore, 'removeActiveAction');
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
expect(addActiveActionSpy).toHaveBeenCalledWith('workflowSaving');
await vi.waitFor(() => {
expect(removeActiveActionSpy).toHaveBeenCalledWith('workflowSaving');
});
});
it('should remove active action even on error', async () => {
const removeActiveActionSpy = vi.spyOn(uiStore, 'removeActiveAction');
workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(new Error('Failed'));
const { getByTestId } = renderComponent({
props: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
});
await userEvent.click(getByTestId('workflow-description-button'));
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
await vi.waitFor(() => {
expect(removeActiveActionSpy).toHaveBeenCalledWith('workflowSaving');
});
});
});
});

View File

@@ -1,239 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import {
N8nButton,
N8nIconButton,
N8nInput,
N8nInputLabel,
N8nPopoverReka,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
type Props = {
workflowId: string;
workflowDescription?: string | null;
};
const props = withDefaults(defineProps<Props>(), {
workflowDescription: '',
});
const i18n = useI18n();
const toast = useToast();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const workflowStore = useWorkflowsStore();
const uiStore = useUIStore();
const descriptionValue = ref(props.workflowDescription);
const popoverOpen = ref(false);
const descriptionInput = useTemplateRef<HTMLInputElement>('descriptionInput');
const isSaving = ref(false);
const lastSavedDescription = ref(props.workflowDescription);
const normalizedCurrentValue = computed(() => (descriptionValue.value ?? '').trim());
const normalizedLastSaved = computed(() => (lastSavedDescription.value ?? '').trim());
const canSave = computed(() => normalizedCurrentValue.value !== normalizedLastSaved.value);
const isMcpEnabled = computed(
() => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
);
const textareaTip = computed(() => {
if (!isMcpEnabled.value) {
return i18n.baseText('workflow.description.tooltip');
}
return i18n.baseText('workflow.description.placeholder.mcp');
});
const saveDescription = async () => {
isSaving.value = true;
uiStore.addActiveAction('workflowSaving');
try {
await workflowStore.saveWorkflowDescription(
props.workflowId,
normalizedCurrentValue.value ?? null,
);
lastSavedDescription.value = descriptionValue.value;
uiStore.stateIsDirty = false;
telemetry.track('User set workflow description', {
workflow_id: props.workflowId,
description: normalizedCurrentValue.value ?? null,
});
} catch (error) {
toast.showError(error, i18n.baseText('workflow.description.error.title'));
descriptionValue.value = lastSavedDescription.value;
} finally {
isSaving.value = false;
uiStore.removeActiveAction('workflowSaving');
}
};
const handlePopoverOpenChange = async (open: boolean) => {
popoverOpen.value = open;
if (open) {
await nextTick();
descriptionInput.value?.focus();
} else {
await saveDescription();
}
};
const handleKeyDown = async (event: KeyboardEvent) => {
// Escape - cancel editing
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
await cancel();
}
// Enter (without Shift) - save and close
if (event.key === 'Enter' && !event.shiftKey) {
if (!canSave.value) {
return;
}
event.preventDefault();
event.stopPropagation();
await save();
}
};
const cancel = async () => {
descriptionValue.value = lastSavedDescription.value;
uiStore.stateIsDirty = false;
popoverOpen.value = false;
};
const save = async () => {
await saveDescription();
popoverOpen.value = false;
};
// Sync with external prop changes
watch(
() => props.workflowDescription,
(newValue) => {
descriptionValue.value = newValue;
lastSavedDescription.value = newValue;
},
);
// Set dirty flag when text changes
watch(descriptionValue, (newValue) => {
const normalizedNewValue = (newValue ?? '').trim();
if (normalizedNewValue !== normalizedLastSaved.value) {
uiStore.stateIsDirty = true;
} else {
uiStore.stateIsDirty = false;
}
});
</script>
<template>
<N8nTooltip :disabled="popoverOpen" :content="i18n.baseText('workflow.description.tooltip')">
<div :class="$style['description-popover-wrapper']" data-test-id="workflow-description-popover">
<N8nPopoverReka
id="workflow-description-popover"
:open="popoverOpen"
@update:open="handlePopoverOpenChange"
>
<template #trigger>
<N8nIconButton
:class="{ [$style['description-button']]: true, [$style.active]: popoverOpen }"
:square="true"
data-test-id="workflow-description-button"
icon="notebook-pen"
type="tertiary"
size="small"
:aria-label="i18n.baseText('workflow.description.tooltip')"
/>
</template>
<template #content>
<div
:class="$style['description-edit-content']"
data-test-id="workflow-description-edit-content"
>
<N8nInputLabel
:label="i18n.baseText('generic.description')"
:tooltip-text="textareaTip"
>
<N8nInput
ref="descriptionInput"
v-model="descriptionValue"
:placeholder="textareaTip"
:rows="6"
data-test-id="workflow-description-input"
type="textarea"
@keydown="handleKeyDown"
/>
</N8nInputLabel>
</div>
<footer :class="$style['popover-footer']">
<N8nButton
:label="i18n.baseText('generic.cancel')"
:size="'small'"
:disabled="isSaving"
type="tertiary"
data-test-id="workflow-description-cancel-button"
@click="cancel"
/>
<N8nButton
:label="i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText')"
:size="'small'"
:loading="isSaving"
:disabled="!canSave || isSaving"
type="primary"
data-test-id="workflow-description-save-button"
@click="save"
/>
</footer>
</template>
</N8nPopoverReka>
</div>
</N8nTooltip>
</template>
<style module lang="scss">
.description-button {
border: none;
position: relative;
&.active {
color: var(--color--background--shade-2);
}
&:hover,
&:focus,
&:focus-visible,
&:active {
background: none;
background-color: transparent !important;
outline: none !important;
color: var(--color--background--shade-2) !important;
}
}
.description-edit-content {
display: flex;
flex-direction: column;
padding: var(--spacing--xs);
width: 400px;
}
.popover-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--xs) var(--spacing--xs);
}
</style>

View File

@@ -42,8 +42,6 @@ import {
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import WorkflowDescriptionPopover from './WorkflowDescriptionPopover.vue';
import { N8nBadge, N8nInlineTextEdit } from '@n8n/design-system';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
@@ -549,11 +547,6 @@ onBeforeUnmount(() => {
>
{{ locale.baseText('workflows.item.archived') }}
</N8nBadge>
<WorkflowDescriptionPopover
v-else-if="!props.readOnly && workflowPermissions.update"
:workflow-id="props.id"
:workflow-description="props.description"
/>
</span>
</span>

View File

@@ -30,6 +30,7 @@ import {
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY,
CONFIRM_PASSWORD_MODAL_KEY,
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
} from '@/app/constants';
@@ -114,6 +115,7 @@ import NodeRecommendationModalV2 from '@/experiments/templateRecoV2/components/N
import NodeRecommendationModalV3 from '@/experiments/personalizedTemplatesV3/components/NodeRecommendationModal.vue';
import NodeRecommendationModalTDQ from '@/experiments/templatesDataQuality/components/NodeRecommendationModal.vue';
import VariableModal from '@/features/settings/environments.ee/components/VariableModal.vue';
import WorkflowDescriptionModal from '@/app/components/WorkflowDescriptionModal.vue';
import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishModal.vue';
import WorkflowHistoryPublishModal from '@/features/workflows/workflowHistory/components/WorkflowHistoryPublishModal.vue';
</script>
@@ -419,6 +421,12 @@ import WorkflowHistoryPublishModal from '@/features/workflows/workflowHistory/co
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_DESCRIPTION_MODAL_KEY">
<template #default="{ modalName, data }">
<WorkflowDescriptionModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_PUBLISH_MODAL_KEY">
<template #default="{ modalName, data }">
<WorkflowPublishModal :modal-name="modalName" :data="data" />

View File

@@ -0,0 +1,490 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import WorkflowDescriptionModal from '@/app/components/WorkflowDescriptionModal.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { STORES } from '@n8n/stores';
import { WORKFLOW_DESCRIPTION_MODAL_KEY } from '../constants';
vi.mock('@/app/composables/useToast', () => {
const showError = vi.fn();
return {
useToast: () => ({
showError,
}),
};
});
vi.mock('@/app/composables/useTelemetry', () => {
const track = vi.fn();
return {
useTelemetry: () => ({
track,
}),
};
});
const initialState = {
[STORES.SETTINGS]: {
settings: {
modules: {
mcp: {
enabled: false,
},
},
},
},
};
const ModalStub = {
template: `
<div>
<slot name="header" />
<slot name="title" />
<slot name="content" />
<slot name="footer" />
</div>
`,
};
const global = {
stubs: {
Modal: ModalStub,
},
};
const renderModal = createComponentRenderer(WorkflowDescriptionModal);
let pinia: ReturnType<typeof createTestingPinia>;
describe('WorkflowDescriptionModal', () => {
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let uiStore: MockedStore<typeof useUIStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let telemetry: ReturnType<typeof useTelemetry>;
let toast: ReturnType<typeof useToast>;
beforeEach(() => {
pinia = createTestingPinia({ initialState });
workflowsStore = mockedStore(useWorkflowsStore);
uiStore = mockedStore(useUIStore);
settingsStore = mockedStore(useSettingsStore);
telemetry = useTelemetry();
toast = useToast();
// Reset mocks
workflowsStore.saveWorkflowDescription = vi.fn().mockResolvedValue(undefined);
workflowsStore.workflow = {
id: 'test-workflow-id',
name: 'Test Workflow',
active: false,
activeVersionId: null,
isArchived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
versionId: '1',
nodes: [],
connections: {},
};
uiStore.stateIsDirty = false;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Component rendering', () => {
it('should render empty string if there is no description', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
expect(textarea).toHaveValue('');
});
});
describe('Popover interaction', () => {
it('should focus textarea when modal opens', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
},
},
pinia,
global,
});
await new Promise((resolve) => setTimeout(resolve, 200));
const textarea = getByTestId('workflow-description-input');
expect(textarea).toHaveFocus();
});
it('should not save description when modal closes by esc', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'Updated description');
await userEvent.type(textarea, '{Esc}');
expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled();
});
});
describe('Save and Cancel functionality', () => {
it('should save description when save button is clicked', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'New description');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith(
'test-workflow-id',
'New description',
);
expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', {
workflow_id: 'test-workflow-id',
description: 'New description',
});
});
it('should save empty string when description is cleared', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith('test-workflow-id', '');
expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', {
workflow_id: 'test-workflow-id',
description: '',
});
});
it('should disable save button when description has not changed', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const saveButton = getByTestId('workflow-description-save-button');
expect(saveButton).toBeDisabled();
});
it('should disable save button when whitespace-only changes result in same trimmed value', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
// Type only whitespace
await userEvent.type(textarea, ' ');
const saveButton = getByTestId('workflow-description-save-button');
// Should be disabled since trimmed value is still empty
expect(saveButton).toBeDisabled();
});
it('should not save on Enter key when only whitespace is entered', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
// Type only whitespace
await userEvent.type(textarea, ' ');
await userEvent.keyboard('{Enter}');
// Should not save since canSave is false
expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled();
});
it('should enable save button when description changes', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
expect(saveButton).not.toBeDisabled();
});
it('should disable cancel button during save', async () => {
workflowsStore.saveWorkflowDescription = vi.fn(
async () => await new Promise((resolve) => setTimeout(resolve, 100)),
);
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
const cancelButton = getByTestId('workflow-description-cancel-button');
await userEvent.click(saveButton);
// During save, cancel should be disabled
expect(cancelButton).toBeDisabled();
});
});
describe('Keyboard shortcuts', () => {
it('should save when Enter key is pressed', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'New description');
await userEvent.keyboard('{Enter}');
expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith(
'test-workflow-id',
'New description',
);
});
it('should allow new lines with Shift+Enter', async () => {
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, 'Line 1');
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await userEvent.type(textarea, 'Line 2');
expect(textarea).toHaveValue('Line 1\nLine 2');
expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled();
});
});
describe('Error handling', () => {
it('should show error toast when save fails', async () => {
const error = new Error('Save failed');
workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error);
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.type(textarea, ' updated');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
await vi.waitFor(() => {
expect(toast.showError).toHaveBeenCalledWith(
error,
'Problem updating workflow description',
);
});
});
it('should keep text on error', async () => {
const error = new Error('Save failed');
workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error);
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: 'Initial description',
},
},
pinia,
global,
});
const textarea = getByTestId('workflow-description-input');
await userEvent.clear(textarea);
await userEvent.type(textarea, 'Failed update');
const saveButton = getByTestId('workflow-description-save-button');
await userEvent.click(saveButton);
await vi.waitFor(() => {
expect(textarea).toHaveValue('Failed update');
});
});
});
describe('MCP tooltips', () => {
it('should show base tooltip when MCP is disabled', async () => {
// Ensure MCP is disabled
settingsStore.isModuleActive = vi.fn().mockReturnValue(false);
settingsStore.moduleSettings.mcp = { mcpAccessEnabled: false };
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
},
pinia,
global,
});
// The tooltip text appears as placeholder in the textarea
const textarea = getByTestId('descriptionTooltip');
const placeholder = textarea.textContent;
expect(placeholder).toContain(
'Clear descriptions help other users understand the purpose of your workflow',
);
expect(placeholder).not.toContain('MCP clients');
});
it('should show MCP tooltip when MCP is enabled', async () => {
// Enable MCP module
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
settingsStore.moduleSettings.mcp = { mcpAccessEnabled: true };
const { getByTestId } = renderModal({
props: {
modalName: WORKFLOW_DESCRIPTION_MODAL_KEY,
data: {
workflowId: 'test-workflow-id',
workflowDescription: '',
},
},
pinia,
global,
});
const textarea = getByTestId('descriptionTooltip');
const placeholder = textarea.textContent;
// When MCP is enabled, the placeholder includes both base tooltip and MCP-specific text
expect(placeholder).toContain('MCP clients');
});
});
});

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { N8nButton, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { WORKFLOW_DESCRIPTION_MODAL_KEY } from '../constants';
import { createEventBus } from '@n8n/utils/event-bus';
import Modal from './Modal.vue';
import { onMounted } from 'vue';
const props = defineProps<{
modalName: string;
data: {
workflowId: string;
workflowDescription?: string | null;
};
}>();
const modalBus = createEventBus();
const i18n = useI18n();
const toast = useToast();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const workflowStore = useWorkflowsStore();
const descriptionValue = ref(props.data.workflowDescription ?? '');
const descriptionInput = useTemplateRef<HTMLInputElement>('descriptionInput');
const isSaving = ref(false);
const normalizedCurrentValue = computed(() => (descriptionValue.value ?? '').trim());
const normalizedLastSaved = computed(() => (props.data.workflowDescription ?? '').trim());
const canSave = computed(() => normalizedCurrentValue.value !== normalizedLastSaved.value);
const isMcpEnabled = computed(
() => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
);
// Descriptive message that educates the user that the description is relevant for MCP
// Updated based on MCP presence
const textareaTip = computed(() =>
isMcpEnabled.value
? i18n.baseText('workflow.description.mcp')
: i18n.baseText('workflow.description.nomcp'),
);
const saveDescription = async () => {
isSaving.value = true;
try {
await workflowStore.saveWorkflowDescription(
props.data.workflowId,
normalizedCurrentValue.value ?? null,
);
telemetry.track('User set workflow description', {
workflow_id: props.data.workflowId,
description: normalizedCurrentValue.value ?? null,
});
} catch (error) {
toast.showError(error, i18n.baseText('workflow.description.error.title'));
} finally {
isSaving.value = false;
}
};
const cancel = () => {
modalBus.emit('close');
};
const save = async () => {
await saveDescription();
modalBus.emit('close');
};
const handleKeyDown = async (event: KeyboardEvent) => {
// Escape - cancel editing
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
cancel();
}
// Enter (without Shift) - save and close
if (event.key === 'Enter' && !event.shiftKey) {
if (!canSave.value) {
return;
}
event.preventDefault();
event.stopPropagation();
await save();
}
};
onMounted(() => {
setTimeout(() => {
descriptionInput.value?.focus();
}, 150);
});
</script>
<template>
<Modal
:name="WORKFLOW_DESCRIPTION_MODAL_KEY"
:title="i18n.baseText('generic.description')"
width="500"
:class="$style.container"
:event-bus="modalBus"
:close-on-click-modal="false"
>
<template #content>
<div
:class="$style['description-edit-content']"
data-test-id="workflow-description-edit-content"
>
<N8nText color="text-base" data-test-id="descriptionTooltip">{{ textareaTip }}</N8nText>
<N8nInput
ref="descriptionInput"
v-model="descriptionValue"
:rows="6"
data-test-id="workflow-description-input"
type="textarea"
@keydown="handleKeyDown"
/>
</div>
</template>
<template #footer>
<div :class="$style['popover-footer']">
<N8nButton
:label="i18n.baseText('generic.cancel')"
:size="'small'"
:disabled="isSaving"
type="tertiary"
data-test-id="workflow-description-cancel-button"
@click="cancel"
/>
<N8nButton
:label="i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText')"
:loading="isSaving"
:disabled="!canSave || isSaving"
type="primary"
data-test-id="workflow-description-save-button"
@click="save"
/>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.description-edit-content {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
padding: var(--spacing--s);
}
.popover-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
}
</style>

View File

@@ -4,6 +4,7 @@ export const enum WORKFLOW_MENU_ACTIONS {
IMPORT_FROM_URL = 'import-from-url',
IMPORT_FROM_FILE = 'import-from-file',
PUSH = 'push',
EDIT_DESCRIPTION = 'edit-description',
SETTINGS = 'settings',
DELETE = 'delete',
ARCHIVE = 'archive',

View File

@@ -35,5 +35,6 @@ export const CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY = 'chatHubSideMenuDrawer';
export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2';
export const EXPERIMENT_TEMPLATE_RECO_V3_KEY = 'templateRecoV3';
export const EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY = 'templatesDataQuality';
export const WORKFLOW_DESCRIPTION_MODAL_KEY = 'workflowDescription';
export const WORKFLOW_PUBLISH_MODAL_KEY = 'workflowPublish';
export const WORKFLOW_HISTORY_PUBLISH_MODAL_KEY = 'workflowHistoryPublish';

View File

@@ -32,6 +32,7 @@ import {
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY,
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
} from '@/app/constants';
@@ -151,6 +152,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
WORKFLOW_DIFF_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
VARIABLE_MODAL_KEY,
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,

View File

@@ -1713,18 +1713,20 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
description,
});
if (workflowsById.value[id]) {
workflowsById.value[id] = {
...workflowsById.value[id],
description: updated.description,
versionId: updated.versionId,
};
}
// Update local store state
if (isCurrentWorkflow) {
setDescription(updated.description ?? '');
if (updated.versionId !== currentVersionId) {
setWorkflowVersionId(updated.versionId);
}
} else if (workflowsById.value[id]) {
workflowsById.value[id] = {
...workflowsById.value[id],
description: updated.description,
versionId: updated.versionId,
};
}
return updated;