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:
Csaba Tuncsik
2023-09-29 17:48:36 +02:00
committed by GitHub
parent ec0379378e
commit d1b6c7fd79
14 changed files with 1019 additions and 12 deletions

View File

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

View 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();
});
});