feat(editor): Workflow history [WIP]- create workflow history list component (no-changelog) (#7186)
Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
@@ -1,19 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { saveAs } from 'file-saver';
|
||||
import { onBeforeMount, ref, watchEffect } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useI18n } from '@/composables';
|
||||
import type {
|
||||
WorkflowHistoryActionTypes,
|
||||
WorkflowVersionId,
|
||||
WorkflowHistoryRequestParams,
|
||||
} from '@/types/workflowHistory';
|
||||
import WorkflowHistoryList from '@/components/WorkflowHistory/WorkflowHistoryList.vue';
|
||||
import WorkflowHistoryContent from '@/components/WorkflowHistory/WorkflowHistoryContent.vue';
|
||||
import { useWorkflowHistoryStore } from '@/stores/workflowHistory.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
type WorkflowHistoryActionRecord = {
|
||||
[K in Uppercase<WorkflowHistoryActionTypes[number]>]: Lowercase<K>;
|
||||
};
|
||||
|
||||
const workflowHistoryActionTypes: WorkflowHistoryActionTypes = [
|
||||
'restore',
|
||||
'clone',
|
||||
'open',
|
||||
'download',
|
||||
];
|
||||
const WORKFLOW_HISTORY_ACTIONS = workflowHistoryActionTypes.reduce(
|
||||
(record, key) => ({ ...record, [key.toUpperCase()]: key }),
|
||||
{} as WorkflowHistoryActionRecord,
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const workflowHistoryStore = useWorkflowHistoryStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const requestNumberOfItems = ref(20);
|
||||
|
||||
const loadMore = async (queryParams: WorkflowHistoryRequestParams) => {
|
||||
const history = await workflowHistoryStore.getWorkflowHistory(
|
||||
route.params.workflowId,
|
||||
queryParams,
|
||||
);
|
||||
workflowHistoryStore.addWorkflowHistory(history);
|
||||
};
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadMore({ take: requestNumberOfItems.value });
|
||||
|
||||
if (!route.params.versionId) {
|
||||
await router.replace({
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: {
|
||||
workflowId: route.params.workflowId,
|
||||
versionId: workflowHistoryStore.workflowHistory[0].versionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const openInNewTab = (id: WorkflowVersionId) => {
|
||||
const { href } = router.resolve({
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: {
|
||||
workflowId: route.params.workflowId,
|
||||
versionId: id,
|
||||
},
|
||||
});
|
||||
window.open(href, '_blank');
|
||||
};
|
||||
|
||||
const downloadVersion = async (id: WorkflowVersionId) => {
|
||||
const workflowVersion = await workflowHistoryStore.getWorkflowVersion(
|
||||
route.params.workflowId,
|
||||
id,
|
||||
);
|
||||
if (workflowVersion?.workflow) {
|
||||
const { workflow } = workflowVersion;
|
||||
const blob = new Blob([JSON.stringify(workflow, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
saveAs(blob, workflow.name.replace(/[^a-z0-9]/gi, '_') + '.json');
|
||||
}
|
||||
};
|
||||
|
||||
const onAction = async ({
|
||||
action,
|
||||
id,
|
||||
}: {
|
||||
action: WorkflowHistoryActionTypes[number];
|
||||
id: WorkflowVersionId;
|
||||
}) => {
|
||||
switch (action) {
|
||||
case WORKFLOW_HISTORY_ACTIONS.OPEN:
|
||||
openInNewTab(id);
|
||||
break;
|
||||
case WORKFLOW_HISTORY_ACTIONS.DOWNLOAD:
|
||||
await downloadVersion(id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onPreview = async ({ event, id }: { event: MouseEvent; id: WorkflowVersionId }) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(id);
|
||||
} else {
|
||||
await router.push({
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: {
|
||||
workflowId: route.params.workflowId,
|
||||
versionId: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onUpgrade = () => {
|
||||
uiStore.goToUpgrade('workflow-history', 'upgrade-workflow-history');
|
||||
};
|
||||
|
||||
watchEffect(async () => {
|
||||
if (route.params.versionId) {
|
||||
const workflowVersion = await workflowHistoryStore.getWorkflowVersion(
|
||||
route.params.workflowId,
|
||||
route.params.versionId,
|
||||
);
|
||||
workflowHistoryStore.setActiveWorkflowVersion(workflowVersion);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="$style.view">
|
||||
<n8n-heading :class="$style.header" tag="h2" size="medium" bold>Workflow name</n8n-heading>
|
||||
<n8n-heading :class="$style.header" tag="h2" size="medium" bold>
|
||||
{{ workflowHistoryStore.activeWorkflowVersion?.workflow?.name }}
|
||||
</n8n-heading>
|
||||
<div :class="$style.corner">
|
||||
<n8n-heading tag="h2" size="medium" bold>{{
|
||||
i18n.baseText('workflowHistory.title')
|
||||
}}</n8n-heading>
|
||||
<n8n-heading tag="h2" size="medium" bold>
|
||||
{{ i18n.baseText('workflowHistory.title') }}
|
||||
</n8n-heading>
|
||||
<n8n-button type="tertiary" icon="times" size="small" text square />
|
||||
</div>
|
||||
<div :class="$style.content"></div>
|
||||
<div :class="$style.list"></div>
|
||||
<workflow-history-content
|
||||
:class="$style.contentComponent"
|
||||
:workflow-version="workflowHistoryStore.activeWorkflowVersion"
|
||||
/>
|
||||
<workflow-history-list
|
||||
:class="$style.listComponent"
|
||||
:items="workflowHistoryStore.workflowHistory"
|
||||
:active-item="workflowHistoryStore.activeWorkflowVersion"
|
||||
:action-types="workflowHistoryActionTypes"
|
||||
:request-number-of-items="requestNumberOfItems"
|
||||
:shouldUpgrade="workflowHistoryStore.shouldUpgrade"
|
||||
:maxRetentionPeriod="workflowHistoryStore.maxRetentionPeriod"
|
||||
@action="onAction"
|
||||
@preview="onPreview"
|
||||
@load-more="loadMore"
|
||||
@upgrade="onUpgrade"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style module lang="scss">
|
||||
@@ -45,12 +188,12 @@ const i18n = useI18n();
|
||||
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.content {
|
||||
.contentComponent {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.list {
|
||||
.listComponent {
|
||||
grid-area: list;
|
||||
grid-area: list;
|
||||
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
156
packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts
Normal file
156
packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { SpyInstance } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { defineComponent } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import WorkflowHistoryPage from '@/views/WorkflowHistory.vue';
|
||||
import { useWorkflowHistoryStore } from '@/stores/workflowHistory.store';
|
||||
import { STORES, VIEWS } from '@/constants';
|
||||
import type { WorkflowHistory } from '@/types/workflowHistory';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const params = {};
|
||||
const push = vi.fn();
|
||||
const replace = vi.fn();
|
||||
const resolve = vi.fn().mockImplementation(() => ({ href: '' }));
|
||||
return {
|
||||
useRoute: () => ({
|
||||
params,
|
||||
}),
|
||||
useRouter: () => ({
|
||||
push,
|
||||
replace,
|
||||
resolve,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const workflowHistoryDataFactory: () => WorkflowHistory = () => ({
|
||||
versionId: faker.string.nanoid(),
|
||||
createdAt: faker.date.past().toDateString(),
|
||||
authors: Array.from({ length: faker.number.int({ min: 2, max: 5 }) }, faker.person.fullName).join(
|
||||
', ',
|
||||
),
|
||||
});
|
||||
|
||||
const workflowVersionDataFactory: () => WorkflowHistory = () => ({
|
||||
...workflowHistoryDataFactory(),
|
||||
workflow: {
|
||||
name: faker.lorem.words(3),
|
||||
},
|
||||
});
|
||||
|
||||
const workflowId = faker.string.nanoid();
|
||||
const historyData = Array.from({ length: 5 }, workflowHistoryDataFactory);
|
||||
const versionData = {
|
||||
...workflowVersionDataFactory(),
|
||||
...historyData[0],
|
||||
};
|
||||
const versionId = faker.string.nanoid();
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowHistoryPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
'workflow-history-content': true,
|
||||
'workflow-history-list': defineComponent({
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: versionId,
|
||||
},
|
||||
},
|
||||
template:
|
||||
'<div><button data-test-id="stub-preview-button" @click="event => $emit(`preview`, {id, event})">Preview</button>button></div>',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
let route: ReturnType<typeof useRoute>;
|
||||
let workflowHistoryStore: ReturnType<typeof useWorkflowHistoryStore>;
|
||||
let windowOpenSpy: SpyInstance;
|
||||
|
||||
describe('WorkflowHistory', () => {
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
|
||||
},
|
||||
});
|
||||
workflowHistoryStore = useWorkflowHistoryStore();
|
||||
route = useRoute();
|
||||
router = useRouter();
|
||||
|
||||
vi.spyOn(workflowHistoryStore, 'workflowHistory', 'get').mockReturnValue(historyData);
|
||||
vi.spyOn(workflowHistoryStore, 'activeWorkflowVersion', 'get').mockReturnValue(versionData);
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should replace url path to contain /:versionId', async () => {
|
||||
route.params.workflowId = workflowId;
|
||||
|
||||
renderComponent({ pinia });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(router.replace).toHaveBeenCalledWith({
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: { workflowId, versionId: versionData.versionId },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should load version data if path contains /:versionId', async () => {
|
||||
const getWorkflowVersionSpy = vi.spyOn(workflowHistoryStore, 'getWorkflowVersion');
|
||||
|
||||
route.params.workflowId = workflowId;
|
||||
route.params.versionId = versionData.versionId;
|
||||
|
||||
renderComponent({ pinia });
|
||||
|
||||
await waitFor(() => expect(router.replace).not.toHaveBeenCalled());
|
||||
expect(getWorkflowVersionSpy).toHaveBeenCalledWith(workflowId, versionData.versionId);
|
||||
});
|
||||
|
||||
it('should change path on preview', async () => {
|
||||
route.params.workflowId = workflowId;
|
||||
|
||||
const { getByTestId } = renderComponent({ pinia });
|
||||
|
||||
await userEvent.click(getByTestId('stub-preview-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: { workflowId, versionId },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should open preview in new tab if meta key used', async () => {
|
||||
route.params.workflowId = workflowId;
|
||||
const { getByTestId } = renderComponent({ pinia });
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.keyboard('[ControlLeft>]');
|
||||
await user.click(getByTestId('stub-preview-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(router.resolve).toHaveBeenCalledWith({
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: { workflowId, versionId },
|
||||
}),
|
||||
);
|
||||
expect(windowOpenSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user