(() =>
);
const onAction = (action: WorkflowHistoryActionTypes[number]) => {
- emit('action', { action, id: props.item.versionId });
+ emit('action', {
+ action,
+ id: props.item.versionId,
+ data: { formattedCreatedAt: formattedCreatedAt.value },
+ });
};
const onVisibleChange = (visible: boolean) => {
@@ -92,7 +100,7 @@ onMounted(() => {
}"
>
-
+
{{ props.item.authors }}
{{ authors.label }}
diff --git a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue
new file mode 100644
index 0000000000..c459f59770
--- /dev/null
+++ b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+ {{ i18n.baseText('workflowHistory.action.restore.modal.title') }}
+
+
+
+
+
+
+
+ {{ props.data.formattedCreatedAt }}
+
+
+
+
+
+
+ “{{
+ i18n.baseText('workflowHistory.action.restore.modal.button.deactivateAndRestore')
+ }}”
+
+
+
+
+
+
+
+ {
+ button.action();
+ closeModal();
+ }
+ "
+ >
+ {{ button.text }}
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryList.test.ts b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryList.test.ts
index a5d8d45899..30b3ccd7e4 100644
--- a/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryList.test.ts
+++ b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryList.test.ts
@@ -91,10 +91,10 @@ describe('WorkflowHistoryList', () => {
await userEvent.click(within(listItem).getByText(/ID: /));
expect(emitted().preview).toEqual([
[
- expect.objectContaining({
+ {
id: items[items.length - 1].versionId,
event: expect.any(MouseEvent),
- }),
+ },
],
]);
@@ -141,7 +141,15 @@ describe('WorkflowHistoryList', () => {
expect(actionsDropdown).toBeInTheDocument();
await userEvent.click(within(actionsDropdown).getByTestId(`action-${action}`));
- expect(emitted().action).toEqual([[{ action, id: items[index].versionId }]]);
+ expect(emitted().action).toEqual([
+ [
+ {
+ action,
+ id: items[index].versionId,
+ data: { formattedCreatedAt: expect.any(String) },
+ },
+ ],
+ ]);
});
it('should show upgrade message', async () => {
diff --git a/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryListItem.test.ts b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryListItem.test.ts
index 3d4120d167..6e88ed9256 100644
--- a/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryListItem.test.ts
+++ b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryListItem.test.ts
@@ -70,7 +70,9 @@ describe('WorkflowHistoryListItem', () => {
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
await userEvent.click(getByTestId(`action-${action}`));
- expect(emitted().action).toEqual([[{ action, id: item.versionId }]]);
+ expect(emitted().action).toEqual([
+ [{ action, id: item.versionId, data: { formattedCreatedAt: expect.any(String) } }],
+ ]);
expect(queryByText(/Latest saved/)).not.toBeInTheDocument();
expect(emitted().mounted).toEqual([[{ index: 2, isActive: true, offsetTop: 0 }]]);
diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts
index 2298860e72..24a1076aac 100644
--- a/packages/editor-ui/src/constants.ts
+++ b/packages/editor-ui/src/constants.ts
@@ -52,6 +52,7 @@ export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush';
export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';
export const DEBUG_PAYWALL_MODAL_KEY = 'debugPaywall';
export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
+export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index 4fbdad1452..6eca3eb212 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -1860,6 +1860,15 @@
"workflowHistory.limit": "Version history is limited to {evaluatedPruneTime} days",
"workflowHistory.upgrade": "{link} to activate full history",
"workflowHistory.upgrade.link": "Upgrade plan",
+ "workflowHistory.action.error.title": "Failed to {action}",
+ "workflowHistory.action.restore.modal.title": "Restore previous workflow version?",
+ "workflowHistory.action.restore.modal.subtitle": "Your workflow will revert to the version from {date}",
+ "workflowHistory.action.restore.modal.text": "Your workflow is currently active, so production executions will immediately start using the restored version. If you'd like to deactivate it before restoring, click {buttonText}.",
+ "workflowHistory.action.restore.modal.button.deactivateAndRestore": "Deactivate and restore",
+ "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.clone.success.title": "Successfully cloned workflow version",
"workflows.heading": "Workflows",
"workflows.add": "Add Workflow",
"workflows.menu.my": "My workflows",
diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts
index e28f3b18fa..0a0de6faff 100644
--- a/packages/editor-ui/src/stores/ui.store.ts
+++ b/packages/editor-ui/src/stores/ui.store.ts
@@ -36,6 +36,7 @@ import {
SOURCE_CONTROL_PULL_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
N8N_PRICING_PAGE_URL,
+ WORKFLOW_HISTORY_VERSION_RESTORE,
} from '@/constants';
import type {
CloudUpdateLinkSourceType,
@@ -157,6 +158,9 @@ export const useUIStore = defineStore(STORES.UI, {
[DEBUG_PAYWALL_MODAL_KEY]: {
open: false,
},
+ [WORKFLOW_HISTORY_VERSION_RESTORE]: {
+ open: false,
+ },
},
modalStack: [],
sidebarMenuCollapsed: true,
diff --git a/packages/editor-ui/src/stores/workflowHistory.store.ts b/packages/editor-ui/src/stores/workflowHistory.store.ts
index 6de3eb352c..c883fc20c8 100644
--- a/packages/editor-ui/src/stores/workflowHistory.store.ts
+++ b/packages/editor-ui/src/stores/workflowHistory.store.ts
@@ -1,17 +1,22 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
-import * as whApi from '@/api/workflowHistory';
-import { useRootStore } from '@/stores/n8nRoot.store';
-import { useSettingsStore } from '@/stores/settings.store';
+import { saveAs } from 'file-saver';
+import type { IWorkflowDataUpdate } from '@/Interface';
import type {
WorkflowHistory,
WorkflowVersion,
WorkflowHistoryRequestParams,
+ WorkflowVersionId,
} from '@/types/workflowHistory';
+import * as whApi from '@/api/workflowHistory';
+import { useRootStore } from '@/stores/n8nRoot.store';
+import { useSettingsStore } from '@/stores/settings.store';
+import { useWorkflowsStore } from '@/stores/workflows.store';
export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
+ const workflowsStore = useWorkflowsStore();
const licensePruneTime = computed(() => settingsStore.settings.workflowHistory.licensePruneTime);
const pruneTime = computed(() => settingsStore.settings.workflowHistory.pruneTime);
@@ -40,9 +45,65 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
return null;
});
+ const downloadVersion = async (workflowId: string, workflowVersionId: WorkflowVersionId) => {
+ const [workflow, workflowVersion] = await Promise.all([
+ workflowsStore.fetchWorkflow(workflowId),
+ getWorkflowVersion(workflowId, workflowVersionId),
+ ]);
+ if (workflow && workflowVersion) {
+ const { connections, nodes } = workflowVersion;
+ const blob = new Blob([JSON.stringify({ ...workflow, nodes, connections }, null, 2)], {
+ type: 'application/json;charset=utf-8',
+ });
+ saveAs(blob, `${workflow.name}-${workflowVersionId}.json`);
+ }
+ };
+
+ const cloneIntoNewWorkflow = async (
+ workflowId: string,
+ workflowVersionId: string,
+ data: { formattedCreatedAt: string },
+ ) => {
+ const [workflow, workflowVersion] = await Promise.all([
+ workflowsStore.fetchWorkflow(workflowId),
+ getWorkflowVersion(workflowId, workflowVersionId),
+ ]);
+ if (workflow && workflowVersion) {
+ const { connections, nodes } = workflowVersion;
+ const { name } = workflow;
+ const newWorkflowData: IWorkflowDataUpdate = {
+ nodes,
+ connections,
+ name: `${name} (${data.formattedCreatedAt})`,
+ };
+ await workflowsStore.createNewWorkflow(newWorkflowData);
+ }
+ };
+
+ const restoreWorkflow = async (
+ workflowId: string,
+ workflowVersionId: string,
+ shouldDeactivate: boolean,
+ ) => {
+ const workflowVersion = await getWorkflowVersion(workflowId, workflowVersionId);
+ if (workflowVersion?.nodes && workflowVersion?.connections) {
+ const { connections, nodes } = workflowVersion;
+ const updateData: IWorkflowDataUpdate = { connections, nodes };
+
+ if (shouldDeactivate) {
+ updateData.active = false;
+ }
+
+ await workflowsStore.updateWorkflow(workflowId, updateData, true);
+ }
+ };
+
return {
getWorkflowHistory,
getWorkflowVersion,
+ downloadVersion,
+ cloneIntoNewWorkflow,
+ restoreWorkflow,
evaluatedPruneTime,
shouldUpgrade,
};
diff --git a/packages/editor-ui/src/views/WorkflowHistory.vue b/packages/editor-ui/src/views/WorkflowHistory.vue
index f87db967cb..0d1cdcc289 100644
--- a/packages/editor-ui/src/views/WorkflowHistory.vue
+++ b/packages/editor-ui/src/views/WorkflowHistory.vue
@@ -1,10 +1,9 @@