diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 00ac8f271f..587c811d90 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -139,6 +139,16 @@ + + + + @@ -172,6 +182,7 @@ import { EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, DEBUG_PAYWALL_MODAL_KEY, MFA_SETUP_MODAL_KEY, + WORKFLOW_HISTORY_VERSION_RESTORE, } from '@/constants'; import AboutModal from './AboutModal.vue'; @@ -202,6 +213,7 @@ import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue'; import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue'; import DebugPaywallModal from '@/components/DebugPaywallModal.vue'; +import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue'; export default defineComponent({ name: 'Modals', @@ -234,6 +246,7 @@ export default defineComponent({ ExternalSecretsProviderModal, DebugPaywallModal, MfaSetupModal, + WorkflowHistoryVersionRestoreModal, }, data: () => ({ CHAT_EMBED_MODAL_KEY, @@ -263,6 +276,7 @@ export default defineComponent({ EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, DEBUG_PAYWALL_MODAL_KEY, MFA_SETUP_MODAL_KEY, + WORKFLOW_HISTORY_VERSION_RESTORE, }), }); diff --git a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryList.vue b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryList.vue index d49472831c..80d954bbbc 100644 --- a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryList.vue +++ b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryList.vue @@ -24,7 +24,11 @@ const props = defineProps<{ const emit = defineEmits<{ ( event: 'action', - value: { action: WorkflowHistoryActionTypes[number]; id: WorkflowVersionId }, + value: { + action: WorkflowHistoryActionTypes[number]; + id: WorkflowVersionId; + data: { formattedCreatedAt: string }; + }, ): void; (event: 'preview', value: { event: MouseEvent; id: WorkflowVersionId }): void; (event: 'loadMore', value: WorkflowHistoryRequestParams): void; @@ -67,12 +71,14 @@ const observeElement = (element: Element) => { const onAction = ({ action, id, + data, }: { action: WorkflowHistoryActionTypes[number]; id: WorkflowVersionId; + data: { formattedCreatedAt: string }; }) => { shouldAutoScroll.value = false; - emit('action', { action, id }); + emit('action', { action, id, data }); }; const onPreview = ({ event, id }: { event: MouseEvent; id: WorkflowVersionId }) => { diff --git a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryListItem.vue b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryListItem.vue index 0e555de1da..7ada3d9cb8 100644 --- a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryListItem.vue +++ b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryListItem.vue @@ -18,7 +18,11 @@ const props = defineProps<{ const emit = defineEmits<{ ( event: 'action', - value: { action: WorkflowHistoryActionTypes[number]; id: WorkflowVersionId }, + value: { + action: WorkflowHistoryActionTypes[number]; + id: WorkflowVersionId; + data: { formattedCreatedAt: string }; + }, ): void; (event: 'preview', value: { event: MouseEvent; id: WorkflowVersionId }): void; (event: 'mounted', value: { index: number; offsetTop: number; isActive: boolean }): void; @@ -31,11 +35,11 @@ const itemElement = ref(null); const authorElement = ref(null); const isAuthorElementTruncated = ref(false); -const formattedCreatedAtDate = computed(() => { +const formattedCreatedAt = computed(() => { const currentYear = new Date().getFullYear().toString(); const [date, time] = dateformat( props.item.createdAt, - `${props.item.createdAt.startsWith(currentYear) ? '' : 'yyyy '} mmm d"#"HH:MM`, + `${props.item.createdAt.startsWith(currentYear) ? '' : 'yyyy '}mmm d"#"HH:MM`, ).split('#'); return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } }); @@ -60,7 +64,11 @@ const idLabel = computed(() => ); 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(() => { }" >

- + {{ 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 @@ + + + + + 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 @@