mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
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:
parent
ec0379378e
commit
d1b6c7fd79
|
@ -474,7 +474,7 @@ export class Server extends AbstractServer {
|
||||||
LICENSE_FEATURES.SHOW_NON_PROD_BANNER,
|
LICENSE_FEATURES.SHOW_NON_PROD_BANNER,
|
||||||
),
|
),
|
||||||
debugInEditor: isDebugInEditorLicensed(),
|
debugInEditor: isDebugInEditorLicensed(),
|
||||||
history: isWorkflowHistoryEnabled(),
|
workflowHistory: isWorkflowHistoryEnabled(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLdapEnabled()) {
|
if (isLdapEnabled()) {
|
||||||
|
|
|
@ -24,6 +24,8 @@ describe('router', () => {
|
||||||
['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG],
|
['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG],
|
||||||
['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT],
|
['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT],
|
||||||
['/workflows/demo', VIEWS.DEMO],
|
['/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) => {
|
])('should resolve %s to %s', async (path, name) => {
|
||||||
await router.push(path);
|
await router.push(path);
|
||||||
expect(router.currentRoute.value.name).toBe(name);
|
expect(router.currentRoute.value.name).toBe(name);
|
||||||
|
|
32
packages/editor-ui/src/api/workflowHistory.ts
Normal file
32
packages/editor-ui/src/api/workflowHistory.ts
Normal 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;
|
||||||
|
};
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 }]]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 }]]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1762,6 +1762,19 @@
|
||||||
"workflowSettings.timeoutWorkflow": "Timeout Workflow",
|
"workflowSettings.timeoutWorkflow": "Timeout Workflow",
|
||||||
"workflowSettings.timezone": "Timezone",
|
"workflowSettings.timezone": "Timezone",
|
||||||
"workflowHistory.title": "Version History",
|
"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.heading": "Workflows",
|
||||||
"workflows.add": "Add Workflow",
|
"workflows.add": "Add Workflow",
|
||||||
"workflows.menu.my": "My workflows",
|
"workflows.menu.my": "My workflows",
|
||||||
|
@ -2090,4 +2103,4 @@
|
||||||
"executionUsage.button.upgrade": "Upgrade plan",
|
"executionUsage.button.upgrade": "Upgrade plan",
|
||||||
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
|
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
|
||||||
"executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating."
|
"executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating."
|
||||||
}
|
}
|
||||||
|
|
|
@ -296,7 +296,7 @@ export const routes = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/workflow/:workflowId/history/:historyId?',
|
path: '/workflow/:workflowId/history/:versionId?',
|
||||||
name: VIEWS.WORKFLOW_HISTORY,
|
name: VIEWS.WORKFLOW_HISTORY,
|
||||||
components: {
|
components: {
|
||||||
default: WorkflowHistory,
|
default: WorkflowHistory,
|
||||||
|
|
56
packages/editor-ui/src/stores/workflowHistory.store.ts
Normal file
56
packages/editor-ui/src/stores/workflowHistory.store.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
19
packages/editor-ui/src/types/workflowHistory.ts
Normal file
19
packages/editor-ui/src/types/workflowHistory.ts
Normal 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 };
|
|
@ -1,19 +1,162 @@
|
||||||
<script setup lang="ts">
|
<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 { 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 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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.view">
|
<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">
|
<div :class="$style.corner">
|
||||||
<n8n-heading tag="h2" size="medium" bold>{{
|
<n8n-heading tag="h2" size="medium" bold>
|
||||||
i18n.baseText('workflowHistory.title')
|
{{ i18n.baseText('workflowHistory.title') }}
|
||||||
}}</n8n-heading>
|
</n8n-heading>
|
||||||
<n8n-button type="tertiary" icon="times" size="small" text square />
|
<n8n-button type="tertiary" icon="times" size="small" text square />
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.content"></div>
|
<workflow-history-content
|
||||||
<div :class="$style.list"></div>
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style module lang="scss">
|
<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);
|
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.contentComponent {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.listComponent {
|
||||||
|
grid-area: list;
|
||||||
grid-area: list;
|
grid-area: list;
|
||||||
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
|
||||||
}
|
}
|
||||||
</style>
|
</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();
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue