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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1019 additions and 12 deletions

View file

@ -474,7 +474,7 @@ export class Server extends AbstractServer {
LICENSE_FEATURES.SHOW_NON_PROD_BANNER,
),
debugInEditor: isDebugInEditorLicensed(),
history: isWorkflowHistoryEnabled(),
workflowHistory: isWorkflowHistoryEnabled(),
});
if (isLdapEnabled()) {

View file

@ -24,6 +24,8 @@ describe('router', () => {
['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG],
['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT],
['/workflows/demo', VIEWS.DEMO],
['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOW_HISTORY],
['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOW_HISTORY],
])('should resolve %s to %s', async (path, name) => {
await router.push(path);
expect(router.currentRoute.value.name).toBe(name);

View file

@ -0,0 +1,32 @@
import type { IRestApiContext } from '@/Interface';
import { get } from '@/utils';
import type {
WorkflowHistory,
WorkflowVersion,
WorkflowHistoryRequestParams,
} from '@/types/workflowHistory';
export const getWorkflowHistory = async (
context: IRestApiContext,
workflowId: string,
queryParams: WorkflowHistoryRequestParams,
): Promise<WorkflowHistory[]> => {
const { data } = await get(
context.baseUrl,
`/workflow-history/workflow/${workflowId}`,
queryParams,
);
return data;
};
export const getWorkflowVersion = async (
context: IRestApiContext,
workflowId: string,
versionId: string,
): Promise<WorkflowVersion> => {
const { data } = await get(
context.baseUrl,
`/workflow-history/workflow/${workflowId}/version/${versionId}`,
);
return data;
};

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { WorkflowVersion } from '@/types/workflowHistory';
const props = defineProps<{
workflowVersion: WorkflowVersion | null;
}>();
</script>
<template>
<div :class="$style.content">
{{ props.workflowVersion }}
</div>
</template>
<style module lang="scss">
.content {
display: block;
}
</style>

View file

@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { UserAction } from 'n8n-design-system';
import { useI18n } from '@/composables';
import type {
WorkflowHistory,
WorkflowVersionId,
WorkflowHistoryActionTypes,
WorkflowHistoryRequestParams,
} from '@/types/workflowHistory';
import WorkflowHistoryListItem from '@/components/WorkflowHistory/WorkflowHistoryListItem.vue';
const props = withDefaults(
defineProps<{
items: WorkflowHistory[];
activeItem: WorkflowHistory | null;
actionTypes: WorkflowHistoryActionTypes;
requestNumberOfItems: number;
shouldUpgrade: boolean;
maxRetentionPeriod: number;
}>(),
{
items: () => [],
shouldUpgrade: false,
maxRetentionPeriod: 0,
},
);
const emit = defineEmits<{
(
event: 'action',
value: { action: WorkflowHistoryActionTypes[number]; id: WorkflowVersionId },
): void;
(event: 'preview', value: { event: MouseEvent; id: WorkflowVersionId }): void;
(event: 'loadMore', value: WorkflowHistoryRequestParams): void;
(event: 'upgrade'): void;
}>();
const i18n = useI18n();
const listElement = ref<Element | null>(null);
const shouldAutoScroll = ref(true);
const observer = ref<IntersectionObserver | null>(null);
const actions = computed<UserAction[]>(() =>
props.actionTypes.map((value) => ({
label: i18n.baseText(`workflowHistory.item.actions.${value}`),
disabled: false,
value,
})),
);
const observeElement = (element: Element) => {
observer.value = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
observer.value?.unobserve(element);
observer.value?.disconnect();
observer.value = null;
emit('loadMore', { take: props.requestNumberOfItems, skip: props.items.length });
}
},
{
root: listElement.value,
threshold: 0.01,
},
);
observer.value.observe(element);
};
const onAction = ({
action,
id,
}: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
}) => {
shouldAutoScroll.value = false;
emit('action', { action, id });
};
const onPreview = ({ event, id }: { event: MouseEvent; id: WorkflowVersionId }) => {
shouldAutoScroll.value = false;
emit('preview', { event, id });
};
const onItemMounted = ({
index,
offsetTop,
isActive,
}: {
index: number;
offsetTop: number;
isActive: boolean;
}) => {
if (isActive && shouldAutoScroll.value) {
shouldAutoScroll.value = false;
listElement.value?.scrollTo({ top: offsetTop, behavior: 'smooth' });
}
if (index === props.items.length - 1 && props.items.length >= props.requestNumberOfItems) {
observeElement(listElement.value?.children[index] as Element);
}
};
</script>
<template>
<ul :class="$style.list" ref="listElement" data-test-id="workflow-history-list">
<workflow-history-list-item
v-for="(item, index) in props.items"
:key="item.versionId"
:index="index"
:item="item"
:is-active="item.versionId === props.activeItem?.versionId"
:actions="actions"
@action="onAction"
@preview="onPreview"
@mounted="onItemMounted"
/>
<li v-if="!props.items.length" :class="$style.empty">
{{ i18n.baseText('workflowHistory.empty') }}
<br />
{{ i18n.baseText('workflowHistory.hint') }}
</li>
<li v-if="props.shouldUpgrade && props.maxRetentionPeriod > 0" :class="$style.retention">
<span>
{{
i18n.baseText('workflowHistory.limit', {
interpolate: { maxRetentionPeriod: props.maxRetentionPeriod },
})
}}
</span>
<i18n-t keypath="workflowHistory.upgrade" tag="span">
<template #link>
<a href="#" @click="emit('upgrade')">
{{ i18n.baseText('workflowHistory.upgrade.link') }}
</a>
</template>
</i18n-t>
</li>
</ul>
</template>
<style module lang="scss">
.list {
height: 100%;
overflow: auto;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: var(--border-width-base);
background-color: var(--color-foreground-base);
}
}
.empty {
display: flex;
position: absolute;
height: 100%;
padding: 0 25%;
justify-content: center;
align-items: center;
text-align: center;
color: var(--color-text-base);
font-size: var(--font-size-s);
line-height: var(--font-line-height-loose);
}
.retention {
display: grid;
padding: var(--spacing-s);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-loose);
text-align: center;
}
</style>

View file

@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import dateformat from 'dateformat';
import type { UserAction } from 'n8n-design-system';
import type {
WorkflowHistory,
WorkflowVersionId,
WorkflowHistoryActionTypes,
} from '@/types/workflowHistory';
import { useI18n } from '@/composables';
const props = defineProps<{
item: WorkflowHistory;
index: number;
actions: UserAction[];
isActive: boolean;
}>();
const emit = defineEmits<{
(
event: 'action',
value: { action: WorkflowHistoryActionTypes[number]; id: WorkflowVersionId },
): void;
(event: 'preview', value: { event: MouseEvent; id: WorkflowVersionId }): void;
(event: 'mounted', value: { index: number; offsetTop: number; isActive: boolean }): void;
}>();
const i18n = useI18n();
const actionsVisible = ref(false);
const itemElement = ref<HTMLElement | null>(null);
const formattedCreatedAtDate = computed<string>(() => {
const currentYear = new Date().getFullYear().toString();
const [date, time] = dateformat(
props.item.createdAt,
`${props.item.createdAt.startsWith(currentYear) ? '' : 'yyyy '} mmm d"#"HH:MM`,
).split('#');
return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } });
});
const authors = computed<{ size: number; label: string }>(() => {
const allAuthors = props.item.authors.split(', ');
let label = allAuthors[0];
if (allAuthors.length > 1) {
label = `${label} + ${allAuthors.length - 1}`;
}
return {
size: allAuthors.length,
label,
};
});
const idLabel = computed<string>(() =>
i18n.baseText('workflowHistory.item.id', { interpolate: { id: props.item.versionId } }),
);
const onAction = (action: WorkflowHistoryActionTypes[number]) => {
emit('action', { action, id: props.item.versionId });
};
const onVisibleChange = (visible: boolean) => {
actionsVisible.value = visible;
};
const onItemClick = (event: MouseEvent) => {
emit('preview', { event, id: props.item.versionId });
};
onMounted(() => {
emit('mounted', {
index: props.index,
offsetTop: itemElement.value?.offsetTop ?? 0,
isActive: props.isActive,
});
});
</script>
<template>
<li
ref="itemElement"
data-test-id="workflow-history-list-item"
:class="{
[$style.item]: true,
[$style.active]: props.isActive,
[$style.actionsVisible]: actionsVisible,
}"
>
<p @click="onItemClick">
<time :datetime="item.createdAt">{{ formattedCreatedAtDate }}</time>
<n8n-tooltip placement="right-end" :disabled="authors.size < 2">
<template #content>{{ props.item.authors }}</template>
<span>{{ authors.label }}</span>
</n8n-tooltip>
<data :value="item.versionId">{{ idLabel }}</data>
</p>
<div :class="$style.tail">
<n8n-badge v-if="props.index === 0">
{{ i18n.baseText('workflowHistory.item.latest') }}
</n8n-badge>
<n8n-action-toggle
theme="dark"
:class="$style.actions"
:actions="props.actions"
@action="onAction"
@click.stop
@visible-change="onVisibleChange"
/>
</div>
</li>
</template>
<style module lang="scss">
.item {
display: flex;
position: relative;
align-items: center;
justify-content: space-between;
border-left: 2px var(--border-style-base) transparent;
border-bottom: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
p {
display: grid;
padding: var(--spacing-s);
line-height: unset;
cursor: pointer;
time {
padding: 0 0 var(--spacing-2xs);
color: var(--color-text-dark);
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
}
span {
justify-self: start;
}
data {
max-width: 160px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-top: var(--spacing-4xs);
font-size: var(--font-size-2xs);
}
}
.tail {
display: flex;
align-items: center;
justify-content: space-between;
}
&.active {
background-color: var(--color-background-base);
border-left-color: var(--color-primary);
p {
cursor: default;
}
}
&:hover,
&.actionsVisible {
border-left-color: var(--color-foreground-xdark);
}
}
.actions {
display: block;
padding: var(--spacing-3xs);
}
</style>

View file

@ -0,0 +1,124 @@
import { within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia';
import { faker } from '@faker-js/faker';
import { createComponentRenderer } from '@/__tests__/render';
import WorkflowHistoryList from '@/components/WorkflowHistory/WorkflowHistoryList.vue';
import type { WorkflowHistory, WorkflowHistoryActionTypes } from '@/types/workflowHistory';
vi.stubGlobal(
'IntersectionObserver',
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
})),
);
const workflowHistoryDataFactory: () => WorkflowHistory = () => ({
versionId: faker.string.nanoid(),
createdAt: faker.date.past().toDateString(),
authors: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, faker.person.fullName).join(
', ',
),
});
const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download'];
const renderComponent = createComponentRenderer(WorkflowHistoryList);
let pinia: ReturnType<typeof createPinia>;
describe('WorkflowHistoryList', () => {
beforeAll(() => {
Element.prototype.scrollTo = vi.fn();
});
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
});
it('should render empty list', () => {
const { getByText } = renderComponent({
pinia,
props: {
items: [],
actionTypes,
activeItem: null,
requestNumberOfItems: 20,
},
});
expect(getByText(/No versions yet/)).toBeInTheDocument();
});
it('should render list and delegate preview event', async () => {
const numberOfItems = faker.number.int({ min: 10, max: 50 });
const items = Array.from({ length: numberOfItems }, workflowHistoryDataFactory);
const { getAllByTestId, emitted } = renderComponent({
pinia,
props: {
items,
actionTypes,
activeItem: null,
requestNumberOfItems: 20,
},
});
const listItems = getAllByTestId('workflow-history-list-item');
const listItem = listItems[items.length - 1];
await userEvent.click(within(listItem).getByText(/ID: /));
expect(emitted().preview).toEqual([
[
expect.objectContaining({
id: items[items.length - 1].versionId,
event: expect.any(MouseEvent),
}),
],
]);
expect(listItems).toHaveLength(numberOfItems);
});
it('should scroll to active item', async () => {
const items = Array.from({ length: 30 }, workflowHistoryDataFactory);
const { getByTestId } = renderComponent({
pinia,
props: {
items,
actionTypes,
activeItem: items[0],
requestNumberOfItems: 20,
},
});
expect(getByTestId('workflow-history-list').scrollTo).toHaveBeenCalled();
});
test.each(actionTypes)('should delegate %s event from item', async (action) => {
const items = Array.from({ length: 2 }, workflowHistoryDataFactory);
const index = 1;
const { getAllByTestId, emitted } = renderComponent({
pinia,
props: {
items,
actionTypes,
activeItem: null,
requestNumberOfItems: 20,
},
});
const listItem = getAllByTestId('workflow-history-list-item')[index];
await userEvent.click(within(listItem).getByTestId('action-toggle'));
const actionsDropdown = getAllByTestId('action-toggle-dropdown')[index];
expect(actionsDropdown).toBeInTheDocument();
await userEvent.click(within(actionsDropdown).getByTestId(`action-${action}`));
expect(emitted().action).toEqual([[{ action, id: items[index].versionId }]]);
});
});

View file

@ -0,0 +1,86 @@
import { createPinia, setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker';
import type { UserAction } from 'n8n-design-system';
import { createComponentRenderer } from '@/__tests__/render';
import WorkflowHistoryListItem from '@/components/WorkflowHistory/WorkflowHistoryListItem.vue';
import type { WorkflowHistory } from '@/types/workflowHistory';
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 actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download'];
const actions: UserAction[] = actionTypes.map((value) => ({
label: value,
disabled: false,
value,
}));
const renderComponent = createComponentRenderer(WorkflowHistoryListItem);
let pinia: ReturnType<typeof createPinia>;
describe('WorkflowHistoryListItem', () => {
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
});
it('should render item with badge', async () => {
const item = workflowHistoryDataFactory();
item.authors = 'John Doe';
const { getByText, container, queryByRole, emitted } = renderComponent({
pinia,
props: {
item,
index: 0,
actions,
isActive: false,
},
});
await userEvent.hover(container.querySelector('.el-tooltip__trigger'));
expect(queryByRole('tooltip')).not.toBeInTheDocument();
await userEvent.click(container.querySelector('p'));
expect(emitted().preview).toEqual([
[expect.objectContaining({ id: item.versionId, event: expect.any(MouseEvent) })],
]);
expect(emitted().mounted).toEqual([[{ index: 0, isActive: false, offsetTop: 0 }]]);
expect(getByText(/Latest saved/)).toBeInTheDocument();
});
test.each(actionTypes)('should emit %s event', async (action) => {
const item = workflowHistoryDataFactory();
const authors = item.authors.split(', ');
const { queryByText, getByRole, getByTestId, container, emitted } = renderComponent({
pinia,
props: {
item,
index: 2,
actions,
isActive: true,
},
});
const authorsTag = container.querySelector('.el-tooltip__trigger');
expect(authorsTag).toHaveTextContent(`${authors[0]} + ${authors.length - 1}`);
await userEvent.hover(authorsTag);
expect(getByRole('tooltip')).toBeInTheDocument();
await userEvent.click(getByTestId('action-toggle'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
await userEvent.click(getByTestId(`action-${action}`));
expect(emitted().action).toEqual([[{ action, id: item.versionId }]]);
expect(queryByText(/Latest saved/)).not.toBeInTheDocument();
expect(emitted().mounted).toEqual([[{ index: 2, isActive: true, offsetTop: 0 }]]);
});
});

View file

@ -1762,6 +1762,19 @@
"workflowSettings.timeoutWorkflow": "Timeout Workflow",
"workflowSettings.timezone": "Timezone",
"workflowHistory.title": "Version History",
"workflowHistory.item.id": "ID: {id}",
"workflowHistory.item.createdAt": "{date} at {time}",
"workflowHistory.item.actions.restore": "Restore this version",
"workflowHistory.item.actions.clone": "Clone to new workflow",
"workflowHistory.item.actions.open": "Open version in new tab",
"workflowHistory.item.actions.download": "Download",
"workflowHistory.item.unsaved.title": "Unsaved version",
"workflowHistory.item.latest": "Latest saved",
"workflowHistory.empty": "No versions yet.",
"workflowHistory.hint": "Save the workflow to create the first version!",
"workflowHistory.limit": "Version history is limited to {maxRetentionPeriod} days",
"workflowHistory.upgrade": "{link} to activate full history",
"workflowHistory.upgrade.link": "Upgrade plan",
"workflows.heading": "Workflows",
"workflows.add": "Add Workflow",
"workflows.menu.my": "My workflows",
@ -2090,4 +2103,4 @@
"executionUsage.button.upgrade": "Upgrade plan",
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
"executionUsage.ranOutOfExecutions.text": "Youre out of executions. Upgrade your plan to keep automating."
}
}

View file

@ -296,7 +296,7 @@ export const routes = [
],
},
{
path: '/workflow/:workflowId/history/:historyId?',
path: '/workflow/:workflowId/history/:versionId?',
name: VIEWS.WORKFLOW_HISTORY,
components: {
default: WorkflowHistory,

View file

@ -0,0 +1,56 @@
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import * as whApi from '@/api/workflowHistory';
import { useRootStore } from '@/stores/n8nRoot.store';
import type {
WorkflowHistory,
WorkflowVersion,
WorkflowHistoryRequestParams,
} from '@/types/workflowHistory';
export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
const rootStore = useRootStore();
const workflowHistory = ref<WorkflowHistory[]>([]);
const activeWorkflowVersion = ref<WorkflowVersion | null>(null);
const maxRetentionPeriod = ref(0);
const retentionPeriod = ref(0);
const shouldUpgrade = computed(() => maxRetentionPeriod.value === retentionPeriod.value);
const getWorkflowHistory = async (
workflowId: string,
queryParams: WorkflowHistoryRequestParams,
): Promise<WorkflowHistory[]> =>
whApi
.getWorkflowHistory(rootStore.getRestApiContext, workflowId, queryParams)
.catch((error) => {
console.error(error);
return [] as WorkflowHistory[];
});
const addWorkflowHistory = (history: WorkflowHistory[]) => {
workflowHistory.value = workflowHistory.value.concat(history);
};
const getWorkflowVersion = async (
workflowId: string,
versionId: string,
): Promise<WorkflowVersion | null> =>
whApi.getWorkflowVersion(rootStore.getRestApiContext, workflowId, versionId).catch((error) => {
console.error(error);
return null;
});
const setActiveWorkflowVersion = (version: WorkflowVersion | null) => {
activeWorkflowVersion.value = version;
};
return {
getWorkflowHistory,
addWorkflowHistory,
getWorkflowVersion,
setActiveWorkflowVersion,
workflowHistory,
activeWorkflowVersion,
shouldUpgrade,
maxRetentionPeriod,
};
});

View file

@ -0,0 +1,19 @@
import type { IWorkflowDb } from '@/Interface';
export type WorkflowHistory = {
versionId: string;
authors: string;
createdAt: string;
};
export type WorkflowVersionId = WorkflowHistory['versionId'];
export type WorkflowVersion = WorkflowHistory & {
nodes: IWorkflowDb['nodes'];
connection: IWorkflowDb['connections'];
workflow: IWorkflowDb;
};
export type WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download'];
export type WorkflowHistoryRequestParams = { take: number; skip?: number };

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