1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-09-20 09:27:44 +03:00

feat(editor): Workflow history [WIP] - Add cloned workflow link to success toast message (no-changelog) (#7405)

This commit is contained in:
Csaba Tuncsik 2023-10-19 14:02:59 +02:00 committed by GitHub
parent 55c6a1b0d3
commit 82129694c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 109 additions and 57 deletions

View File

@ -19,17 +19,14 @@ export function useToast() {
const externalHooks = useExternalHooks();
const i18n = useI18n();
function showMessage(
messageData: Omit<NotificationOptions, 'message'> & { message?: string },
track = true,
) {
function showMessage(messageData: NotificationOptions, track = true) {
messageData = { ...messageDefaults, ...messageData };
messageData.message = messageData.message
? sanitizeHtml(messageData.message)
: messageData.message;
messageData.message =
typeof messageData.message === 'string'
? sanitizeHtml(messageData.message)
: messageData.message;
// @TODO Check if still working
const notification = Notification(messageData as NotificationOptions);
const notification = Notification(messageData);
if (messageData.duration === 0) {
stickyNotificationQueue.push(notification);
@ -49,7 +46,7 @@ export function useToast() {
function showToast(config: {
title: string;
message: string;
message: NotificationOptions['message'];
onClick?: () => void;
onClose?: () => void;
duration?: number;

View File

@ -1884,6 +1884,7 @@
"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",
"workflowHistory.action.clone.success.message": "Open cloned workflow in a new tab",
"workflows.heading": "Workflows",
"workflows.add": "Add Workflow",
"workflows.menu.my": "My workflows",

View File

@ -1,7 +1,7 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { saveAs } from 'file-saver';
import type { IWorkflowDataUpdate } from '@/Interface';
import type { IWorkflowDataUpdate, IWorkflowDb } from '@/Interface';
import type {
WorkflowHistory,
WorkflowVersion,
@ -12,6 +12,7 @@ import * as whApi from '@/api/workflowHistory';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { getNewWorkflow } from '@/api/workflows';
export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
const rootStore = useRootStore();
@ -34,7 +35,7 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
const getWorkflowVersion = async (
workflowId: string,
versionId: string,
): Promise<WorkflowVersion | null> =>
): Promise<WorkflowVersion> =>
whApi.getWorkflowVersion(rootStore.getRestApiContext, workflowId, versionId);
const downloadVersion = async (
@ -46,34 +47,34 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
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}(${data.formattedCreatedAt}).json`);
}
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}(${data.formattedCreatedAt}).json`);
};
const cloneIntoNewWorkflow = async (
workflowId: string,
workflowVersionId: string,
data: { formattedCreatedAt: string },
) => {
): Promise<IWorkflowDb> => {
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 { connections, nodes } = workflowVersion;
const { name } = workflow;
const newWorkflow = await getNewWorkflow(
rootStore.getRestApiContext,
`${name} (${data.formattedCreatedAt})`,
);
const newWorkflowData: IWorkflowDataUpdate = {
nodes,
connections,
name: newWorkflow.name,
};
return workflowsStore.createNewWorkflow(newWorkflowData);
};
const restoreWorkflow = async (

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeMount, ref, watchEffect, computed } from 'vue';
import { onBeforeMount, ref, watchEffect, computed, h } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { IWorkflowDb, UserAction } from '@/Interface';
import { VIEWS, WORKFLOW_HISTORY_VERSION_RESTORE } from '@/constants';
@ -162,6 +162,58 @@ const openRestorationModal = async (
});
};
const cloneWorkflowVersion = async (
id: WorkflowVersionId,
data: { formattedCreatedAt: string },
) => {
const clonedWorkflow = await workflowHistoryStore.cloneIntoNewWorkflow(
route.params.workflowId,
id,
data,
);
const { href } = router.resolve({
name: VIEWS.WORKFLOW,
params: {
name: clonedWorkflow.id,
},
});
toast.showMessage({
title: i18n.baseText('workflowHistory.action.clone.success.title'),
message: h(
'a',
{ href, target: '_blank' },
i18n.baseText('workflowHistory.action.clone.success.message'),
),
type: 'success',
duration: 10000,
});
};
const restoreWorkflowVersion = async (
id: WorkflowVersionId,
data: { formattedCreatedAt: string },
) => {
const workflow = await workflowsStore.fetchWorkflow(route.params.workflowId);
const modalAction = await openRestorationModal(workflow.active, data.formattedCreatedAt);
if (modalAction === WorkflowHistoryVersionRestoreModalActions.cancel) {
return;
}
await workflowHistoryStore.restoreWorkflow(
route.params.workflowId,
id,
modalAction === WorkflowHistoryVersionRestoreModalActions.deactivateAndRestore,
);
const history = await workflowHistoryStore.getWorkflowHistory(route.params.workflowId, {
take: 1,
});
workflowHistory.value = history.concat(workflowHistory.value);
toast.showMessage({
title: i18n.baseText('workflowHistory.action.restore.success.title'),
type: 'success',
});
};
const onAction = async ({
action,
id,
@ -180,31 +232,10 @@ const onAction = async ({
await workflowHistoryStore.downloadVersion(route.params.workflowId, id, data);
break;
case WORKFLOW_HISTORY_ACTIONS.CLONE:
await workflowHistoryStore.cloneIntoNewWorkflow(route.params.workflowId, id, data);
toast.showMessage({
title: i18n.baseText('workflowHistory.action.clone.success.title'),
type: 'success',
});
await cloneWorkflowVersion(id, data);
break;
case WORKFLOW_HISTORY_ACTIONS.RESTORE:
const workflow = await workflowsStore.fetchWorkflow(route.params.workflowId);
const modalAction = await openRestorationModal(workflow.active, data.formattedCreatedAt);
if (modalAction === WorkflowHistoryVersionRestoreModalActions.cancel) {
break;
}
await workflowHistoryStore.restoreWorkflow(
route.params.workflowId,
id,
modalAction === WorkflowHistoryVersionRestoreModalActions.deactivateAndRestore,
);
const history = await workflowHistoryStore.getWorkflowHistory(route.params.workflowId, {
take: 1,
});
workflowHistory.value = history.concat(workflowHistory.value);
toast.showMessage({
title: i18n.baseText('workflowHistory.action.restore.success.title'),
type: 'success',
});
await restoreWorkflowVersion(id, data);
break;
}
} catch (error) {

View File

@ -1,6 +1,6 @@
import type { SpyInstance } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { defineComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@ -54,8 +54,10 @@ const renderComponent = createComponentRenderer(WorkflowHistoryPage, {
default: versionId,
},
},
template:
'<div><button data-test-id="stub-preview-button" @click="event => $emit(`preview`, {id, event})">Preview</button>button></div>',
template: `<div>
<button data-test-id="stub-preview-button" @click="event => $emit('preview', {id, event})" />
<button data-test-id="stub-clone-button" @click="() => $emit('action', { action: 'clone', id })" />
</div>`,
}),
},
},
@ -147,4 +149,24 @@ describe('WorkflowHistory', () => {
);
expect(windowOpenSpy).toHaveBeenCalled();
});
it('should clone workflow from version data', async () => {
route.params.workflowId = workflowId;
const newWorkflowId = faker.string.nanoid();
vi.spyOn(workflowHistoryStore, 'cloneIntoNewWorkflow').mockResolvedValue({
id: newWorkflowId,
} as IWorkflowDb);
const { getByTestId, getByRole } = renderComponent({ pinia });
await userEvent.click(getByTestId('stub-clone-button'));
await waitFor(() =>
expect(router.resolve).toHaveBeenCalledWith({
name: VIEWS.WORKFLOW,
params: { name: newWorkflowId },
}),
);
expect(within(getByRole('alert')).getByRole('link')).toBeInTheDocument();
});
});