This commit is contained in:
Rob 2026-01-08 14:28:59 +00:00
parent 317ba0d3fc
commit 841c3a68a2
13 changed files with 1898 additions and 91 deletions

1412
build.log Normal file

File diff suppressed because one or more lines are too long

View File

@ -67,6 +67,7 @@ export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.d
export { TransferWorkflowBodyDto } from './workflows/transfer.dto';
export { ActivateWorkflowDto } from './workflows/activate-workflow.dto';
export { SaveWorkflowVersionDto } from './workflows/save-workflow-version.dto';
export { UpdateWorkflowVersionDto } from './workflows/update-workflow-version.dto';
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';

View File

@ -0,0 +1,11 @@
import { Z } from 'zod-class';
import {
workflowVersionNameSchema,
workflowVersionDescriptionSchema,
} from '../../schemas/workflow-version.schema';
export class UpdateWorkflowVersionDto extends Z.class({
name: workflowVersionNameSchema.optional(),
description: workflowVersionDescriptionSchema.optional(),
}) {}

View File

@ -1,5 +1,9 @@
import { PaginationDto, WorkflowHistoryVersionsByIdsDto } from '@n8n/api-types';
import { RestController, Get, Post, Query, Body } from '@n8n/decorators';
import {
PaginationDto,
WorkflowHistoryVersionsByIdsDto,
UpdateWorkflowVersionDto,
} from '@n8n/api-types';
import { RestController, Get, Post, Patch, Query, Body } from '@n8n/decorators';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error';
@ -69,4 +73,27 @@ export class WorkflowHistoryController {
throw e;
}
}
@Patch('/workflow/:workflowId/version/:versionId')
async updateVersion(
req: WorkflowHistoryRequest.GetVersion,
_res: Response,
@Body body: UpdateWorkflowVersionDto,
) {
try {
return await this.historyService.updateVersionWithUser(
req.user,
req.params.workflowId,
req.params.versionId,
body,
);
} catch (e) {
if (e instanceof SharedWorkflowNotFoundError) {
throw new NotFoundError('Could not find workflow');
} else if (e instanceof WorkflowHistoryVersionNotFoundError) {
throw new NotFoundError('Could not find version');
}
throw e;
}
}
}

View File

@ -138,6 +138,41 @@ export class WorkflowHistoryService {
await this.workflowHistoryRepository.update({ versionId, workflowId }, { ...updateData });
}
async updateVersionWithUser(
user: User,
workflowId: string,
versionId: string,
updateData: Pick<WorkflowHistoryUpdate, 'name' | 'description'>,
) {
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:update',
]);
if (!workflow) {
throw new SharedWorkflowNotFoundError('');
}
const version = await this.workflowHistoryRepository.findOne({
where: {
workflowId: workflow.id,
versionId,
},
});
if (!version) {
throw new WorkflowHistoryVersionNotFoundError('');
}
await this.workflowHistoryRepository.update({ versionId, workflowId }, { ...updateData });
return await this.workflowHistoryRepository.findOne({
where: {
workflowId,
versionId,
},
});
}
/**
* Get multiple versions by their IDs
* Returns only versions that exist, skipping non-existent ones

View File

@ -45,6 +45,7 @@
"generic.docs": "Docs",
"generic.documentation": "Documentation",
"generic.back": "Back",
"generic.cancel": "Cancel",
"generic.connect": "Connect",
"generic.open": "Open",
@ -222,7 +223,6 @@
"auth.signup.setupYourAccountError": "Problem setting up your account",
"auth.signup.tokenValidationError": "Issue validating invite token",
"aiAssistant.name": "n8n AI",
"aiAssistant.assistant": "Assistant",
"aiAssistant.tabs.ask": "Ask",
"aiAssistant.tabs.build": "Build",
"aiAssistant.builder.mode": "AI Builder",
@ -235,7 +235,6 @@
"aiAssistant.builder.invalidPrompt": "Prompt validation failed. Please try again with a clearer description of your workflow requirements and supported integrations.",
"aiAssistant.builder.workflowParsingError.title": "Unable to insert workflow",
"aiAssistant.builder.workflowParsingError.content": "The workflow returned by AI could not be parsed. Please try again.",
"aiAssistant.builder.error.title": "Workflow update failed",
"aiAssistant.builder.canvasPrompt.title": "What would you like to automate?",
"aiAssistant.builder.canvasPrompt.confirmTitle": "Replace current prompt?",
"aiAssistant.builder.canvasPrompt.confirmMessage": "This will replace your current prompt. Are you sure?",
@ -261,12 +260,7 @@
"aiAssistant.builder.restoreError.title": "Failed to restore version",
"aiAssistant.builder.disabledTooltip.autosaving": "Saving workflow...",
"aiAssistant.builder.disabledTooltip.readOnly": "Another user is currently editing this workflow",
"aiAssistant.builder.notification.title": "Workflow ready - n8n",
"aiAssistant.builder.notification.body": "Done building {workflowName}",
"aiAssistant.builder.notification.inputNeeded.title": "Input needed - n8n",
"aiAssistant.builder.notification.inputNeeded.body": "Waiting for your response on {workflowName}",
"aiAssistant.builder.notificationBanner.text": "Want to be notified when the agent finishes building?",
"aiAssistant.builder.notificationBanner.notify": "Notify",
"aiAssistant.assistant": "n8n AI",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",
"aiAssistant.newSessionModal.message": "You already have an active n8n AI session. Starting a new session will clear your current conversation history.",
@ -278,7 +272,7 @@
"aiAssistant.codeUpdated.message.body1": "Open the",
"aiAssistant.codeUpdated.message.body2": "node to see the changes",
"aiAssistant.thinkingSteps.analyzingError": "Analyzing the error...",
"aiAssistant.thinkingSteps.thinking": "Thinking",
"aiAssistant.thinkingSteps.thinking": "Thinking...",
"aiAssistant.prompts.currentView.workflowList": "The user is currently looking at the list of workflows.",
"aiAssistant.prompts.currentView.credentialsList": "The user is currently looking at the list of credentials.",
"aiAssistant.prompts.currentView.executionsView": "The user is currently looking at the list of executions for the currently open workflow.",
@ -448,9 +442,6 @@
"chatHub.error.updateModelFailed": "Failed to update model",
"chatHub.error.updateToolsFailed": "Failed to update tools",
"chatHub.models.selector.defaultLabel": "Select model",
"chatHub.models.selector.personalProject": "Personal",
"chatHub.models.selector.noMatch": "No agents match your search criteria.",
"chatHub.models.selector.moreModels": "More {providerName} models...",
"chatHub.models.byIdSelector.title": "Choose {provider} model by ID",
"chatHub.models.byIdSelector.choose": "Enter model identifier (e.g. \"gpt-4\")",
"chatHub.models.byIdSelector.confirm": "Select",
@ -786,11 +777,7 @@
"codeNodeEditor.optional": "optional",
"codeNodeEditor.defaultsTo": "Defaults to {default}.",
"collectionParameter.choose": "Choose...",
"collectionParameter.addItem": "Add item",
"collectionParameter.deleteItem": "Delete",
"collectionParameter.dragItem": "Drag to reorder",
"collectionParameter.noProperties": "No properties",
"collectionParameter.allOptionsAdded": "All options have been added",
"communityNodeFooter.legacy": "Legacy",
"communityNodeFooter.manage": "Manage",
"communityNodeFooter.reportIssue": "Report issue",
@ -1198,14 +1185,10 @@
"expressionModalInput.pairedItemInvalidPinnedError": "Unpin node ‘{node}’ and execute",
"expressionModalInput.pairedItemError": "Can’t determine which item to use",
"expressionModalInput.pairedItemError.noRunData": "Can't determine which item to use - execute node for more info",
"fixedCollectionParameter.addItem": "Add item",
"fixedCollectionParameter.addParameter": "Add {parameter}",
"fixedCollectionParameter.allOptionsAdded": "All options have been added",
"fixedCollectionParameter.addField": "Add Field",
"fixedCollectionParameter.choose": "Choose...",
"fixedCollectionParameter.currentlyNoItemsExist": "Currently no items exist",
"fixedCollectionParameter.deleteItem": "Delete item",
"fixedCollectionParameter.deleteParameter": "Delete {parameter}",
"fixedCollectionParameter.dragItem": "Drag item",
"fixedCollectionParameter.moveDown": "Move down",
"fixedCollectionParameter.moveUp": "Move up",
@ -1231,6 +1214,8 @@
"folders.add": "Add folder",
"folders.add.here.message": "Create a new folder here",
"folders.add.to.parent.message": "Create folder in '{parent}'",
"folders.add.overview.community.message": "Folders available in your personal space",
"folders.add.overview.withProjects.message": "Folders available in projects or your personal space",
"folders.add.success.title": "Folder created",
"folders.add.success.message": "Created new folder '{folderName}'<br><a href=\"{link}\">Open folder</a>",
"folders.invalidName.empty.message": "Folder name cannot be empty",
@ -1515,8 +1500,6 @@
"ndv.tooMuchData.showDataAnyway": "Show data",
"ndv.tooMuchData.title": "Show {size} MB of data?",
"ndv.httpRequest.credentialOnly.docsNotice": "Use the <a target=\"_blank\" href=\"{docsUrl}\">{nodeName} docs</a> to construct your request. We'll take care of the authentication part if you add a {nodeName} credential below.",
"ndv.binaryData.combinedTooltip": "Specify the path to the binary data entry in the input item or use an expression to access the binary data in previous nodes, e.g. {example}",
"ndv.binaryData.separateTooltip": "Specify the property name of the binary data in the input item or use an expression to access the binary data in previous nodes, e.g. {example}",
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
"noAnnotationTagsView.title": "Organize your executions",
@ -1678,6 +1661,8 @@
"nodeCreator.nodeItem.aiIconTitle": "LangChain AI Node",
"nodeCreator.nodeItem.deprecated": "Deprecated",
"nodeCreator.nodeItem.beta": "Beta",
"nodeCreator.preBuiltAgents.title": "Pre-built agents",
"nodeCreator.preBuiltAgents.description": "Get started faster with ready to go agents",
"nodeCredentials.createNew": "Create new credential",
"nodeCredentials.createNew.permissionDenied": "Your current role does not allow you to create credentials",
"nodeCredentials.credentialFor": "Credential for {credentialType}",
@ -2325,8 +2310,6 @@
"runData.executionStatus.failed": "Execution failed",
"runData.executionStatus.canceled": "Execution canceled",
"runData.downloadBinaryData": "Download",
"runData.downloadBinaryData.error.title": "Error downloading file",
"runData.downloadBinaryData.error.message": "File could not be downloaded",
"runData.executeNode": "Test Node",
"runData.executionTime": "Execution Time",
"runData.fileExtension": "File Extension",
@ -2347,7 +2330,6 @@
"runData.nodeReturnedALargeAmountOfData": "Node returned a large amount of data",
"runData.output": "Output",
"runData.showBinaryData": "View",
"runData.legacyBinaryMode": "Try the new \"v2\" Execution Logic mode by selecting the corresponding option in the workflow",
"runData.startTime": "Start Time",
"runData.table": "Table",
"runData.table.viewSubExecution": "View sub-execution {id}",
@ -2523,7 +2505,6 @@
"settings.users.actions.delete": "Delete User",
"settings.users.actions.reinvite": "Resend Invite",
"settings.users.actions.copyInviteLink": "Copy Invite Link",
"settings.users.actions.generateInviteLink": "Generate Invite Link",
"settings.users.actions.copyPasswordResetLink": "Copy Password Reset Link",
"settings.users.actions.allowSSOManualLogin": "Allow Manual Login",
"settings.users.actions.disallowSSOManualLogin": "Disallow Manual Login",
@ -2545,7 +2526,6 @@
"settings.users.inviteXUser.inviteUrl": "Create {count} invite links",
"settings.users.inviteUrlCreated": "Invite link copied to clipboard",
"settings.users.inviteUrlCreated.message": "Send the invite link to your invitee for activation",
"settings.users.inviteLinkError": "Could not generate invite link",
"settings.users.passwordResetUrlCreated": "Password reset link copied to clipboard",
"settings.users.passwordResetUrlCreated.message": "Send the reset link to your user for them to reset their password",
"settings.users.passwordResetLinkError": "Could not retrieve password reset link",
@ -2849,7 +2829,7 @@
"settings.sourceControl.modals.push.buttons.save": "Commit and push",
"settings.sourceControl.modals.push.success.title": "Pushed successfully",
"settings.sourceControl.modals.push.success.description": "were committed and pushed to your remote repository",
"settings.sourceControl.modals.push.projectAdmin.callout": "If you want to push workflows from your personal space, move them to a project first.",
"settings.sourceControl.modals.push.projectAdmin.callout": "If you want to push workflows from your personal space, move then to a project first.",
"settings.sourceControl.status.modified": "Modified",
"settings.sourceControl.status.deleted": "Deleted",
"settings.sourceControl.status.created": "New",
@ -3174,18 +3154,8 @@
"workflowRun.showError.title": "Problem running workflow",
"workflowRun.showError.payloadTooLarge": "Please execute the whole workflow, rather than just the node. (Existing execution data is too large.)",
"workflowRun.showError.resolveOutstandingIssues": "Please resolve outstanding issues before you activate it",
"workflowRun.showError.unsupportedExecutionLogic.title": "Unsupported Execution Logic",
"workflowRun.showError.unsupportedExecutionLogic.description": "Execution Logic \"v2\" is not supported when filesystem mode is \"default\". Please change the Execution Logic to \"v1\" in workflow settings and update expressions accordingly.",
"workflowRun.showMessage.message": "Please fix them before executing",
"workflowRun.showMessage.title": "Workflow has issues",
"workflowSettings.executionLogic.v0.title": "v0 (legacy)",
"workflowSettings.executionLogic.v1.title": "v1 (recommended)",
"workflowSettings.executionLogic.v2.title": "v2 (experimental)",
"workflowSettings.executionLogic.v0.description": "Executes the first node of each branch, then the second node of each branch, and so on.",
"workflowSettings.executionLogic.v1.description": "Executes each branch in turn, from topmost to bottommost, completing one branch before starting another.",
"workflowSettings.executionLogic.v2.description": "Executes each branch in turn, with binaries included directly in the item structure to simplify expressions.",
"workflowSettings.executionLogic.changed.title": "Execution Logic changed",
"workflowSettings.executionLogic.changed.description": "Please update expressions that reference binary data to match the new execution logic mode. ",
"workflowSettings.callerIds": "IDs of workflows that can call this one",
"workflowSettings.callerIds.placeholder": "e.g. 14, 18",
"workflowSettings.callerPolicy": "This workflow can be called by",
@ -3198,8 +3168,7 @@
"workflowSettings.defaultTimezone": "Default - {defaultTimezoneValue}",
"workflowSettings.defaultTimezoneNotValid": "Default Timezone not valid",
"workflowSettings.errorWorkflow": "Error Workflow (to notify when this one errors)",
"workflowSettings.executionLogic": "Execution Logic",
"workflowSettings.binaryMode": "Binary Mode",
"workflowSettings.executionOrder": "Execution Order",
"workflowSettings.helpTexts.errorWorkflow": "A second workflow to run if the current one fails.<br />The second workflow should have an 'Error Trigger' node.",
"workflowSettings.helpTexts.executionTimeout": "How long the workflow should wait before timing out",
"workflowSettings.helpTexts.executionTimeoutToggle": "Whether to cancel workflow execution after a defined time",
@ -3282,12 +3251,6 @@
"credentialResolverEdit.sidebar.details": "Details",
"credentialResolverEdit.details.id": "ID",
"credentialResolverEdit.details.notSaved": "Not saved yet",
"credentialResolverEdit.clearCredentials.label": "Clear credentials",
"credentialResolverEdit.clearCredentials.placeholder": "Select an option",
"credentialResolverEdit.clearCredentials.yes": "Yes",
"credentialResolverEdit.clearCredentials.no": "No",
"credentialResolverEdit.clearCredentials.error.required": "Clear credentials selection is required",
"credentialResolverEdit.clearCredentials.warning": "When making changes to a resolver, it is advised to clear the existing credentials for that resolver. Please choose whether to take that action.",
"workflowSettings.timeSavedPerExecution": "Estimated time saved",
"workflowSettings.timeSavedPerExecution.hint": "Minutes per production execution",
"workflowSettings.timeSavedPerExecution.tooltip": "Total time savings are summarised in the Overview page.",
@ -3317,15 +3280,16 @@
"workflowHistory.item.actions.clone": "Clone to new workflow",
"workflowHistory.item.actions.open": "Open version in new tab",
"workflowHistory.item.actions.download": "Download",
"workflowHistory.item.actions.saveAsNamed": "Create draft",
"workflowHistory.item.actions.rename": "Rename version",
"workflowHistory.item.unsaved.title": "Unsaved version",
"workflowHistory.item.latest": "Latest saved",
"workflowHistory.item.active": "Published",
"workflowHistory.item.publishedBy": "Published by",
"workflowHistory.item.currentChanges": "Current changes",
"workflowHistory.limitDay": "Version history is limited to 1 day",
"workflowHistory.limitDays": "Version history is limited to {days} days",
"workflowHistory.limitHour": "Version history is limited to 1 hour",
"workflowHistory.limitHours": "Version history is limited to {hours} hours",
"workflowHistory.item.createdBy": "Created by",
"workflowHistory.item.neverPublished": "Never published",
"workflowHistory.limit": "Version history is limited to {days} days",
"workflowHistory.upgrade": "{link} to activate full history",
"workflowHistory.upgrade.link": "Upgrade plan",
"workflowHistory.action.error.title": "Failed to {action}",
@ -3335,7 +3299,9 @@
"workflowHistory.action.restore.modal.button.restore": "Restore",
"workflowHistory.action.restore.modal.button.cancel": "Cancel",
"workflowHistory.action.restore.success.title": "Successfully restored workflow version",
"workflowHistory.action.saveAsNamed.success.title": "Draft created successfully",
"workflowHistory.action.restore.alreadyRestored": "Workflow version is already restored",
"workflowHistory.action.rename.success.title": "Version renamed successfully",
"workflowHistory.action.clone.success.title": "Successfully cloned workflow version",
"workflowHistory.action.clone.success.message": "Open cloned workflow in a new tab",
"workflowHistory.action.unpublish.success.title": "Workflow unpublished successfully",
@ -3412,6 +3378,7 @@
"workflows.empty.learnN8n": "Learn n8n",
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
"workflows.empty.easyAI": "Test a simple AI Agent example",
"workflows.empty.preBuiltAgents": "Try a pre-built agent",
"workflows.empty.shared-with-me": "No {resource} has been shared with you",
"workflows.empty.shared-with-me.link": "<a href=\"#\">Back to Personal</a>",
"workflows.empty.readyToRun": "Try an AI workflow",
@ -3465,6 +3432,8 @@
"workflows.readyToRunWorkflows.error": "Error loading n8n collection. Please try again later.",
"workflows.archivedOnly.hint": "Archived workflows are hidden in this view. {link}",
"workflows.archivedOnly.hint.link": "Update filters",
"workflows.preBuiltAgents.callout": "Get started faster with our",
"workflows.preBuiltAgents.linkText": "pre-built agents",
"workflowSelectorParameterInput.createNewSubworkflow.name": "My Sub-Workflow",
"workflows.publishModal.title": "Publish workflow",
"workflows.publishModal.noTriggerMessage": "This workflow has no trigger nodes that require publishing",
@ -3473,6 +3442,17 @@
"workflows.publishModal.noChanges": "No changes to publish",
"workflows.publishModal.changes": "New changes to publish",
"workflows.publishModal.lastPublished": "Last published by {author}, {date} at {time}",
"workflows.saveDraft": "Create draft",
"workflows.saveDraft.title": "Create draft",
"workflows.saveDraft.success.title": "Draft created successfully",
"workflows.saveDraft.success.message": "Your draft version has been created",
"workflows.saveDraft.error.title": "Failed to create draft",
"workflows.saveDraft.error.message": "There was an error creating your draft version",
"workflows.renameVersion.title": "Rename version",
"workflows.renameVersion.success.title": "Version renamed successfully",
"workflows.renameVersion.success.message": "Your version has been renamed",
"workflows.renameVersion.error.title": "Failed to rename version",
"workflows.renameVersion.error.message": "There was an error renaming your version",
"importCurlModal.title": "Import cURL command",
"importCurlModal.input.label": "cURL Command",
"importCurlModal.input.placeholder": "Paste the cURL command here",
@ -4015,9 +3995,6 @@
"assignment.addAll": "Add All",
"assignment.clearAll": "Clear All",
"assignment.binaryData.placeholder": "Name of binary input (or expression referencing it)",
"assignment.field": "Field {number}",
"assignment.remove": "Remove field",
"assignment.dragToReorder": "Drag to reorder",
"templateSetup.title": "Set up '{name}' template",
"templateSetup.instructions": "You need {0} account to setup this template",
"templateSetup.skip": "Skip",
@ -4165,7 +4142,7 @@
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
"freeAi.credits.callout.claim.button.label": "Claim credits",
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",
"freeAi.credits.callout.success.title.part2": "gpt-5-mini, gpt-4.1-mini, gpt-4.1-nano, gpt-4o-mini, text-embedding-3-small, dall-e-3, tts-1, whisper-1, and text-moderation-latest",
"freeAi.credits.callout.success.title.part2": "gpt-4o-mini, text-embedding-3-small, dall-e-3, tts-1, whisper-1, and text-moderation-latest",
"freeAi.credits.credentials.edit": "This is a managed credential and cannot be edited.",
"freeAi.credits.showError.claim.title": "Free AI credits",
"freeAi.credits.showError.claim.message": "Enable to claim credits",
@ -4243,6 +4220,9 @@
"workflowDiff.newWorkflow": "New workflow",
"workflowDiff.newWorkflow.database": "The workflow will be created in the database",
"workflowDiff.newWorkflow.remote": "The workflow will be created on remote",
"preBuiltAgentTemplates.title": "Pre-built agents",
"preBuiltAgentTemplates.tutorials": "Tutorial templates",
"preBuiltAgentTemplates.viewAllLink": "View all templates",
"commandBar.placeholder": "Type a command or search...",
"commandBar.noResults": "No results found",
"commandBar.sections.recent": "Recent",
@ -4321,13 +4301,5 @@
"commandBar.chat.deleteCurrent": "Delete current chat",
"commandBar.chat.deleteSession": "Delete \"{title}\"",
"commandBar.chat.open.searchPlaceholder": "Search by f title...",
"commandBar.chat.stop": "Stop message generation",
"browserNotifications.permission.request.title": "Enable desktop notifications",
"browserNotifications.permission.request.message": "Get notified when your workflows complete or need attention.",
"browserNotifications.permission.request.allow": "Allow notifications",
"browserNotifications.permission.request.dismiss": "Not now",
"browserNotifications.permission.denied.title": "Notifications blocked",
"browserNotifications.permission.denied.message": "Desktop notifications are blocked. To enable them, click the lock icon in your browser's address bar and allow notifications.",
"browserNotifications.permission.denied.checkAgain": "Check again",
"browserNotifications.permission.granted.message": "Desktop notifications are enabled"
"commandBar.chat.stop": "Stop message generation"
}

View File

@ -1,7 +1,7 @@
import type { IConnections, INode } from 'n8n-workflow';
import type { IRestApiContext } from '../types';
import { get, post } from '../utils';
import { get, post, patch } from '../utils';
export type WorkflowHistory = {
versionId: string;
@ -31,7 +31,7 @@ export type WorkflowVersion = WorkflowHistory & {
};
export type WorkflowHistoryActionTypes = Array<
'restore' | 'publish' | 'unpublish' | 'clone' | 'open' | 'download'
'restore' | 'publish' | 'unpublish' | 'clone' | 'open' | 'download' | 'saveAsNamed' | 'rename'
>;
export type WorkflowHistoryRequestParams = { take: number; skip?: number };
@ -73,3 +73,17 @@ export const getWorkflowVersionsByIds = async (
);
return data;
};
export const updateWorkflowVersion = async (
context: IRestApiContext,
workflowId: string,
versionId: string,
body: { name?: string; description?: string },
): Promise<WorkflowHistory> => {
const { data } = await patch(
context.baseUrl,
`/workflow-history/workflow/${workflowId}/version/${versionId}`,
body,
);
return data;
};

View File

@ -34,6 +34,7 @@ import {
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_VERSION_RENAME_MODAL_KEY,
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
} from '@/app/constants';
import {
@ -120,6 +121,7 @@ import StopManyExecutionsModal from './StopManyExecutionsModal.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';
import WorkflowVersionRenameModal from '@/features/workflows/workflowHistory/components/WorkflowVersionRenameModal.vue';
import UpdatesPanel from './UpdatesPanel.vue';
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
</script>
@ -449,6 +451,12 @@ import CredentialResolverEditModal from '@/app/components/CredentialResolverEdit
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_VERSION_RENAME_MODAL_KEY">
<template #default="{ modalName, data }">
<WorkflowVersionRenameModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_RESOLVER_EDIT_MODAL_KEY">
<template #default="{ modalName, data }">
<CredentialResolverEditModal :modal-name="modalName" :data="data" />

View File

@ -38,4 +38,6 @@ export const BINARY_DATA_VIEW_MODAL_KEY = 'binaryDataView';
export const WORKFLOW_DESCRIPTION_MODAL_KEY = 'workflowDescription';
export const WORKFLOW_PUBLISH_MODAL_KEY = 'workflowPublish';
export const WORKFLOW_HISTORY_PUBLISH_MODAL_KEY = 'workflowHistoryPublish';
export const WORKFLOW_SAVE_DRAFT_MODAL_KEY = 'workflowSaveDraft';
export const WORKFLOW_VERSION_RENAME_MODAL_KEY = 'workflowVersionRename';
export const CREDENTIAL_RESOLVER_EDIT_MODAL_KEY = 'credentialResolverEdit';

View File

@ -9,7 +9,9 @@ import type {
import { useI18n } from '@n8n/i18n';
import type { IUser } from 'n8n-workflow';
import { N8nActionToggle, N8nTooltip, N8nText } from '@n8n/design-system';
import { N8nTooltip, N8nText, N8nIconButton } from '@n8n/design-system';
import N8nDropdownMenu from '@n8n/design-system/v2/components/DropdownMenu/DropdownMenu.vue';
import type { DropdownMenuItemProps } from '@n8n/design-system/v2/components/DropdownMenu/DropdownMenu.types';
import {
getLastPublishedVersion,
formatTimestamp,
@ -97,6 +99,14 @@ const versionPublishInfo = computed(() => {
return publishInfo;
});
const isNeverActivated = computed(() => {
// A version is never activated if it has a name but no publish history
return (
props.item.name &&
(!props.item.workflowPublishHistory || props.item.workflowPublishHistory.length === 0)
);
});
const getPublishedUserName = (userId: string | undefined | null) => {
if (!userId) {
return null;
@ -118,6 +128,10 @@ const mainTooltipContent = computed(() => {
return i18n.baseText('workflowHistory.item.currentChanges');
}
if (isNeverActivated.value) {
return i18n.baseText('workflowHistory.item.neverPublished');
}
if (versionPublishInfo.value) {
return `${i18n.baseText('workflowHistory.item.publishedBy')}`;
}
@ -165,6 +179,14 @@ const mainTooltipFormattedDate = computed(() => {
return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } });
});
const dropdownMenuItems = computed<Array<DropdownMenuItemProps<string>>>(() => {
return props.actions.map((action) => ({
id: action.value,
label: action.label,
disabled: action.disabled,
}));
});
const onAction = (value: string) => {
const action = value as WorkflowHistoryActionTypes[number];
emit('action', {
@ -201,6 +223,9 @@ onMounted(() => {
<div v-if="props.index === 0 && !props.isVersionActive">
{{ mainTooltipContent }}
</div>
<div v-else-if="isNeverActivated">
{{ mainTooltipContent }}
</div>
<div v-else>
{{ mainTooltipContent }}
<template v-if="mainTooltipUser">
@ -232,6 +257,10 @@ onMounted(() => {
v-else-if="props.index === 0 && !props.isVersionActive"
:class="[$style.timelineDot, $style.timelineDotLatest]"
/>
<span
v-else-if="isNeverActivated"
:class="[$style.timelineDot, $style.timelineDotDraft]"
/>
<span v-else :class="[$style.timelineDot, $style.timelineDotDefault]" />
</template>
<span v-else :class="$style.timelineLine" />
@ -285,14 +314,25 @@ onMounted(() => {
</N8nText>
</div>
</div>
<N8nActionToggle
:class="$style.actions"
:actions="props.actions"
<N8nDropdownMenu
:items="dropdownMenuItems"
:teleported="true"
placement="bottom-end"
@action="onAction"
@click.stop
@visible-change="onVisibleChange"
/>
@select="onAction"
@update:model-value="onVisibleChange"
>
<template #trigger>
<N8nIconButton
:class="$style.actions"
icon="ellipsis"
type="highlight"
size="small"
aria-label="More actions"
data-test-id="workflow-history-item-actions"
@click.stop
/>
</template>
</N8nDropdownMenu>
</div>
</li>
</N8nTooltip>
@ -384,6 +424,12 @@ $hoverBackground: var(--color--background--light-1);
border-color: var(--color--mint-600);
}
.timelineDotDraft {
background-color: var(--color--foreground--tint-1);
border: var(--border);
border-color: var(--color--foreground--tint-1);
}
.timelineLine {
@include timeline-line-style;
position: absolute;

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import Modal from '@/app/components/Modal.vue';
import { WORKFLOW_VERSION_RENAME_MODAL_KEY } from '@/app/constants';
import { useWorkflowHistoryStore } from '@/features/workflows/workflowHistory/workflowHistory.store';
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { N8nHeading, N8nButton } from '@n8n/design-system';
import WorkflowPublishForm from '@/app/components/WorkflowPublishForm.vue';
import { useToast } from '@/app/composables/useToast';
import type { WorkflowHistory } from '@n8n/rest-api-client/api/workflowHistory';
const modalBus = createEventBus();
const i18n = useI18n();
const workflowHistoryStore = useWorkflowHistoryStore();
const { showMessage } = useToast();
const saving = ref(false);
const publishForm = useTemplateRef<InstanceType<typeof WorkflowPublishForm>>('publishForm');
const description = ref('');
const versionName = ref('');
export type WorkflowVersionRenameModalEventBusEvents = {
renamed: { version: WorkflowHistory };
};
const props = defineProps<{
modalName: string;
data: {
workflowId: string;
versionId: string;
version: WorkflowHistory;
eventBus?: EventBus<WorkflowVersionRenameModalEventBusEvents>;
};
}>();
const data = ref<{
workflowId: string;
versionId: string;
version: WorkflowHistory;
eventBus?: EventBus<WorkflowVersionRenameModalEventBusEvents>;
}>({
workflowId: '',
versionId: '',
version: {} as WorkflowHistory,
});
const isSaveDisabled = computed(() => {
return saving.value || versionName.value.trim().length === 0;
});
function onModalOpened() {
if (publishForm.value) {
publishForm.value.focusInput();
}
}
onMounted(() => {
if (props.data) {
data.value = props.data;
versionName.value = props.data.version.name || '';
description.value = props.data.version.description || '';
}
modalBus.on('opened', onModalOpened);
});
onBeforeUnmount(() => {
modalBus.off('opened', onModalOpened);
});
async function handleRename() {
if (isSaveDisabled.value) {
return;
}
saving.value = true;
try {
const updatedVersion = await workflowHistoryStore.renameVersion(
data.value.workflowId,
data.value.versionId,
{
name: versionName.value,
description: description.value,
},
);
showMessage({
title: i18n.baseText('workflows.renameVersion.success.title'),
message: i18n.baseText('workflows.renameVersion.success.message'),
type: 'success',
});
if (data.value.eventBus) {
data.value.eventBus.emit('renamed', {
version: updatedVersion,
});
}
modalBus.emit('close');
} catch (error) {
showMessage({
title: i18n.baseText('workflows.renameVersion.error.title'),
message:
error instanceof Error
? error.message
: i18n.baseText('workflows.renameVersion.error.message'),
type: 'error',
});
}
saving.value = false;
}
</script>
<template>
<Modal
max-width="500px"
max-height="85vh"
:name="WORKFLOW_VERSION_RENAME_MODAL_KEY"
:center="true"
:show-close="true"
:close-on-click-modal="false"
:event-bus="modalBus"
>
<template #header>
<N8nHeading size="xlarge">{{ i18n.baseText('workflows.renameVersion.title') }}</N8nHeading>
</template>
<template #content>
<div :class="$style.content">
<WorkflowPublishForm
ref="publishForm"
v-model:version-name="versionName"
v-model:description="description"
:disabled="saving"
version-name-test-id="workflow-rename-version-name-input"
description-test-id="workflow-rename-version-description-input"
@submit="handleRename"
/>
<div :class="$style.actions">
<N8nButton
:disabled="saving"
type="secondary"
label="Cancel"
data-test-id="workflow-rename-version-cancel-button"
@click="modalBus.emit('close')"
/>
<N8nButton
:disabled="isSaveDisabled"
:loading="saving"
label="Save"
data-test-id="workflow-rename-version-save-button"
@click="handleRename"
/>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing--xs);
}
</style>

View File

@ -6,6 +6,8 @@ import {
VIEWS,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_SAVE_DRAFT_MODAL_KEY,
WORKFLOW_VERSION_RENAME_MODAL_KEY,
} from '@/app/constants';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
@ -32,12 +34,10 @@ import { N8nBadge, N8nButton, N8nHeading } from '@n8n/design-system';
import { createEventBus } from '@n8n/utils/event-bus';
import type { WorkflowHistoryVersionUnpublishModalEventBusEvents } from '../components/WorkflowHistoryVersionUnpublishModal.vue';
import type { WorkflowHistoryPublishModalEventBusEvents } from '../components/WorkflowHistoryPublishModal.vue';
import type { WorkflowSaveDraftModalEventBusEvents } from '@/app/components/MainHeader/WorkflowSaveDraftModal.vue';
import type { WorkflowVersionRenameModalEventBusEvents } from '../components/WorkflowVersionRenameModal.vue';
import type { WorkflowHistoryAction } from '@/features/workflows/workflowHistory/types';
type WorkflowHistoryActionRecord = {
[K in Uppercase<WorkflowHistoryActionTypes[number]>]: Lowercase<K>;
};
const workflowHistoryActionTypes: WorkflowHistoryActionTypes = [
'restore',
'publish',
@ -45,11 +45,9 @@ const workflowHistoryActionTypes: WorkflowHistoryActionTypes = [
'clone',
'open',
'download',
'saveAsNamed',
'rename',
];
const WORKFLOW_HISTORY_ACTIONS = workflowHistoryActionTypes.reduce(
(record, key) => ({ ...record, [key.toUpperCase()]: key }),
{} as WorkflowHistoryActionRecord,
);
const route = useRoute();
const router = useRouter();
@ -92,12 +90,14 @@ const actions = computed<Array<UserAction<IUser>>>(() =>
disabled:
(value === 'clone' && !workflowPermissions.value.create) ||
(value === 'restore' && !workflowPermissions.value.update) ||
((value === 'publish' || value === 'unpublish') && !workflowPermissions.value.update),
((value === 'publish' || value === 'unpublish') && !workflowPermissions.value.update) ||
(value === 'rename' && !workflowPermissions.value.update),
value,
})),
);
const isFirstItemShown = computed(() => workflowHistory.value[0]?.versionId === versionId.value);
const evaluatedPruneDays = computed(() => Math.floor(workflowHistoryStore.evaluatedPruneTime / 24));
const sendTelemetry = (event: string) => {
telemetry.track(event, {
@ -289,31 +289,126 @@ const unpublishWorkflowVersion = (id: WorkflowVersionId, data: WorkflowHistoryAc
});
};
const renameWorkflowVersion = (id: WorkflowVersionId, data: WorkflowHistoryAction['data']) => {
const renameEventBus = createEventBus<WorkflowVersionRenameModalEventBusEvents>();
renameEventBus.once('renamed', ({ version }) => {
// Update the history list with the new name and description
const historyItem = workflowHistory.value.find((item) => item.versionId === version.versionId);
if (historyItem) {
historyItem.name = version.name;
historyItem.description = version.description;
}
// Update the selected workflow version if it's the one that was renamed
if (selectedWorkflowVersion.value?.versionId === version.versionId) {
selectedWorkflowVersion.value = {
...selectedWorkflowVersion.value,
name: version.name,
description: version.description,
};
}
toast.showMessage({
title: i18n.baseText('workflowHistory.action.rename.success.title'),
type: 'success',
});
sendTelemetry('User renamed version from history');
});
// Find the version in the history to get current name and description
const version = workflowHistory.value.find((item) => item.versionId === id);
uiStore.openModalWithData({
name: WORKFLOW_VERSION_RENAME_MODAL_KEY,
data: {
workflowId: workflowId.value,
versionId: id,
version: version || { versionId: id, name: data.versionName, description: data.description },
eventBus: renameEventBus,
},
});
};
const saveVersionAsNamed = async (
versionId: WorkflowVersionId,
_data: WorkflowHistoryAction['data'],
) => {
const saveDraftEventBus = createEventBus<WorkflowSaveDraftModalEventBusEvents>();
saveDraftEventBus.once('saved', async (savedData) => {
// Update the history item with the new name and description
const historyItem = workflowHistory.value.find(
(item) => item.versionId === savedData.versionId,
);
if (historyItem) {
historyItem.name = savedData.name;
historyItem.description = savedData.description || null;
}
// Close the modal
uiStore.closeModal(WORKFLOW_SAVE_DRAFT_MODAL_KEY);
sendTelemetry('User converted autosaved version to named version');
// Reload workflow history to ensure we have the latest data
// Clear current history and reload
workflowHistory.value = [];
await loadMore({ take: requestNumberOfItems.value });
// Navigate to the newly created named version
await router.push({
name: VIEWS.WORKFLOW_HISTORY,
params: {
workflowId: workflowId.value,
versionId: savedData.versionId,
},
});
});
// Open the save draft modal with the version ID and event bus
uiStore.openModalWithData({
name: WORKFLOW_SAVE_DRAFT_MODAL_KEY,
data: {
workflowId: workflowId.value,
versionId,
eventBus: saveDraftEventBus,
},
});
};
const onAction = async ({ action, id, data }: WorkflowHistoryAction) => {
try {
switch (action) {
case WORKFLOW_HISTORY_ACTIONS.OPEN:
case 'open':
openInNewTab(id);
sendTelemetry('User opened version in new tab');
break;
case WORKFLOW_HISTORY_ACTIONS.DOWNLOAD:
case 'download':
await workflowHistoryStore.downloadVersion(workflowId.value, id, data);
sendTelemetry('User downloaded version');
break;
case WORKFLOW_HISTORY_ACTIONS.CLONE:
case 'clone':
await cloneWorkflowVersion(id, data);
sendTelemetry('User cloned version');
break;
case WORKFLOW_HISTORY_ACTIONS.RESTORE:
case 'restore':
await restoreWorkflowVersion(id);
sendTelemetry('User restored version');
break;
case WORKFLOW_HISTORY_ACTIONS.PUBLISH:
case 'publish':
publishWorkflowVersion(id, data);
break;
case WORKFLOW_HISTORY_ACTIONS.UNPUBLISH:
case 'unpublish':
unpublishWorkflowVersion(id, data);
break;
case 'saveAsNamed':
await saveVersionAsNamed(id, data);
break;
case 'rename':
renameWorkflowVersion(id, data);
break;
}
} catch (error) {
toast.showError(
@ -407,7 +502,7 @@ watchEffect(async () => {
:actions="actions"
:request-number-of-items="requestNumberOfItems"
:should-upgrade="workflowHistoryStore.shouldUpgrade"
:evaluated-prune-time-in-hours="workflowHistoryStore.evaluatedPruneTime"
:evaluated-prune-days="evaluatedPruneDays"
:is-list-loading="isListLoading"
:active-version-id="workflowActiveVersionId"
@action="onAction"

View File

@ -103,12 +103,21 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
});
};
const renameVersion = async (
workflowId: string,
versionId: string,
data: { name?: string; description?: string },
): Promise<WorkflowHistory> => {
return await whApi.updateWorkflowVersion(rootStore.restApiContext, workflowId, versionId, data);
};
return {
getWorkflowHistory,
getWorkflowVersion,
downloadVersion,
cloneIntoNewWorkflow,
restoreWorkflow,
renameVersion,
evaluatedPruneTime,
shouldUpgrade,
};