mirror of
https://github.com/n8n-io/n8n.git
synced 2025-12-05 19:27:26 -06:00
feat(editor): Move workflow description edit button to settings (#22301)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user