feat(editor): Workflow history [WIP]- Create workflow history item preview component (no-changelog) (#7378)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
Csaba Tuncsik
2023-10-11 10:13:04 +02:00
committed by GitHub
parent 965db8f7f2
commit 53c3379282
14 changed files with 366 additions and 97 deletions

View File

@@ -122,7 +122,11 @@ export default defineComponent({
.activator { .activator {
cursor: pointer; cursor: pointer;
padding: var(--spacing-2xs); display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin: 0; margin: 0;
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
line-height: normal !important; line-height: normal !important;
@@ -133,7 +137,7 @@ export default defineComponent({
&:hover { &:hover {
background-color: var(--color-background-base); background-color: var(--color-background-base);
color: initial !important; color: var(--color-primary);
} }
} }

View File

@@ -7,12 +7,14 @@
@command="onCommand" @command="onCommand"
@visible-change="onVisibleChange" @visible-change="onVisibleChange"
> >
<span :class="{ [$style.button]: true, [$style[theme]]: !!theme }"> <slot>
<n8n-icon <span :class="{ [$style.button]: true, [$style[theme]]: !!theme }">
:icon="iconOrientation === 'horizontal' ? 'ellipsis-h' : 'ellipsis-v'" <n8n-icon
:size="iconSize" :icon="iconOrientation === 'horizontal' ? 'ellipsis-h' : 'ellipsis-v'"
/> :size="iconSize"
</span> />
</span>
</slot>
<template #dropdown> <template #dropdown>
<el-dropdown-menu data-test-id="action-toggle-dropdown"> <el-dropdown-menu data-test-id="action-toggle-dropdown">

View File

@@ -722,8 +722,16 @@ $--header-spacing: 20px;
} }
.workflowHistoryButton { .workflowHistoryButton {
margin-left: var(--spacing-l); width: 30px;
height: 30px;
margin-left: var(--spacing-m);
margin-right: var(--spacing-4xs);
color: var(--color-text-dark); color: var(--color-text-dark);
border-radius: var(--border-radius-base);
&:hover {
background-color: var(--color-background-base);
}
:disabled { :disabled {
background: transparent; background: transparent;

View File

@@ -1,14 +1,110 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WorkflowVersion } from '@/types/workflowHistory'; import { computed } from 'vue';
import type { IWorkflowDb, UserAction } from '@/Interface';
import type {
WorkflowVersion,
WorkflowHistoryActionTypes,
WorkflowVersionId,
} from '@/types/workflowHistory';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import WorkflowHistoryListItem from '@/components/WorkflowHistory/WorkflowHistoryListItem.vue';
import { useI18n } from '@/composables';
const i18n = useI18n();
const props = defineProps<{ const props = defineProps<{
workflow: IWorkflowDb | null;
workflowVersion: WorkflowVersion | null; workflowVersion: WorkflowVersion | null;
actions: UserAction[];
isListLoading?: boolean;
}>(); }>();
const emit = defineEmits<{
(
event: 'action',
value: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: { formattedCreatedAt: string };
},
): void;
}>();
const workflowVersionPreview = computed<IWorkflowDb | undefined>(() => {
if (!props.workflowVersion || !props.workflow) {
return;
}
return {
...props.workflow,
nodes: props.workflowVersion.nodes,
connections: props.workflowVersion.connections,
};
});
const onAction = ({
action,
id,
data,
}: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: { formattedCreatedAt: string };
}) => {
emit('action', { action, id, data });
};
</script> </script>
<template> <template>
<div :class="$style.content"> <div :class="$style.content">
{{ props.workflowVersion }} <WorkflowPreview
v-if="props.workflowVersion"
:workflow="workflowVersionPreview"
:loading="props.isListLoading"
loaderType="spinner"
/>
<ul :class="$style.info">
<workflow-history-list-item
:class="$style.card"
v-if="props.workflowVersion"
:full="true"
:index="-1"
:item="props.workflowVersion"
:isActive="false"
:actions="props.actions"
@action="onAction"
>
<template #default="{ formattedCreatedAt }">
<section :class="$style.text">
<p>
<span :class="$style.label">
{{ i18n.baseText('workflowHistory.content.title') }}:
</span>
<time :datetime="props.workflowVersion.createdAt">{{ formattedCreatedAt }}</time>
</p>
<p>
<span :class="$style.label">
{{ i18n.baseText('workflowHistory.content.editedBy') }}:
</span>
<span>{{ props.workflowVersion.authors }}</span>
</p>
<p>
<span :class="$style.label">
{{ i18n.baseText('workflowHistory.content.versionId') }}:
</span>
<data :value="props.workflowVersion.versionId">{{
props.workflowVersion.versionId
}}</data>
</p>
</section>
</template>
<template #action-toggle-button>
<n8n-button type="tertiary" size="small" data-test-id="action-toggle-button">
{{ i18n.baseText('workflowHistory.content.actions') }}
<n8n-icon class="ml-3xs" icon="chevron-down" size="small" />
</n8n-button>
</template>
</workflow-history-list-item>
</ul>
</div> </div>
</template> </template>
@@ -20,5 +116,63 @@ const props = defineProps<{
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: auto;
}
.info {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
}
.card {
padding: var(--spacing-s) var(--spacing-l) 0 var(--spacing-xl);
border: 0;
align-items: start;
.text {
display: flex;
flex-direction: column;
flex: 1 1 auto;
p {
display: flex;
align-items: center;
padding: 0;
cursor: default;
&:first-child {
padding-top: var(--spacing-3xs);
padding-bottom: var(--spacing-3xs);
* {
font-size: var(--font-size-m);
}
}
&:last-child {
padding-top: var(--spacing-3xs);
* {
font-size: var(--font-size-2xs);
}
}
.label {
padding-right: var(--spacing-4xs);
}
* {
max-width: unset;
justify-self: unset;
white-space: unset;
overflow: hidden;
text-overflow: unset;
padding: 0;
font-size: var(--font-size-s);
}
}
}
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { ref } from 'vue';
import type { UserAction } from 'n8n-design-system'; import type { UserAction } from 'n8n-design-system';
import { useI18n } from '@/composables'; import { useI18n } from '@/composables';
import type { import type {
@@ -13,7 +13,7 @@ import WorkflowHistoryListItem from '@/components/WorkflowHistory/WorkflowHistor
const props = defineProps<{ const props = defineProps<{
items: WorkflowHistory[]; items: WorkflowHistory[];
activeItem: WorkflowHistory | null; activeItem: WorkflowHistory | null;
actionTypes: WorkflowHistoryActionTypes; actions: UserAction[];
requestNumberOfItems: number; requestNumberOfItems: number;
lastReceivedItemsLength: number; lastReceivedItemsLength: number;
evaluatedPruneTime: number; evaluatedPruneTime: number;
@@ -41,14 +41,6 @@ const listElement = ref<Element | null>(null);
const shouldAutoScroll = ref(true); const shouldAutoScroll = ref(true);
const observer = ref<IntersectionObserver | null>(null); 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) => { const observeElement = (element: Element) => {
observer.value = new IntersectionObserver( observer.value = new IntersectionObserver(
([entry]) => { ([entry]) => {
@@ -116,8 +108,8 @@ const onItemMounted = ({
:key="item.versionId" :key="item.versionId"
:index="index" :index="index"
:item="item" :item="item"
:is-active="item.versionId === props.activeItem?.versionId" :isActive="item.versionId === props.activeItem?.versionId"
:actions="actions" :actions="props.actions"
@action="onAction" @action="onAction"
@preview="onPreview" @preview="onPreview"
@mounted="onItemMounted" @mounted="onItemMounted"

View File

@@ -39,7 +39,7 @@ const formattedCreatedAt = computed<string>(() => {
const currentYear = new Date().getFullYear().toString(); const currentYear = new Date().getFullYear().toString();
const [date, time] = dateformat( const [date, time] = dateformat(
props.item.createdAt, props.item.createdAt,
`${props.item.createdAt.startsWith(currentYear) ? '' : 'yyyy '}mmm d"#"HH:MM`, `${props.item.createdAt.startsWith(currentYear) ? '' : 'yyyy '}mmm d"#"HH:MM:ss`,
).split('#'); ).split('#');
return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } }); return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } });
@@ -99,14 +99,19 @@ onMounted(() => {
[$style.actionsVisible]: actionsVisible, [$style.actionsVisible]: actionsVisible,
}" }"
> >
<p @click="onItemClick"> <slot :formattedCreatedAt="formattedCreatedAt">
<time :datetime="item.createdAt">{{ formattedCreatedAt }}</time> <p @click="onItemClick">
<n8n-tooltip placement="right-end" :disabled="authors.size < 2 && !isAuthorElementTruncated"> <time :datetime="item.createdAt">{{ formattedCreatedAt }}</time>
<template #content>{{ props.item.authors }}</template> <n8n-tooltip
<span ref="authorElement">{{ authors.label }}</span> placement="right-end"
</n8n-tooltip> :disabled="authors.size < 2 && !isAuthorElementTruncated"
<data :value="item.versionId">{{ idLabel }}</data> >
</p> <template #content>{{ props.item.authors }}</template>
<span ref="authorElement">{{ authors.label }}</span>
</n8n-tooltip>
<data :value="item.versionId">{{ idLabel }}</data>
</p>
</slot>
<div :class="$style.tail"> <div :class="$style.tail">
<n8n-badge v-if="props.index === 0"> <n8n-badge v-if="props.index === 0">
{{ i18n.baseText('workflowHistory.item.latest') }} {{ i18n.baseText('workflowHistory.item.latest') }}
@@ -115,10 +120,13 @@ onMounted(() => {
theme="dark" theme="dark"
:class="$style.actions" :class="$style.actions"
:actions="props.actions" :actions="props.actions"
placement="bottom-end"
@action="onAction" @action="onAction"
@click.stop @click.stop
@visible-change="onVisibleChange" @visible-change="onVisibleChange"
/> >
<slot name="action-toggle-button" />
</n8n-action-toggle>
</div> </div>
</li> </li>
</template> </template>
@@ -136,11 +144,11 @@ onMounted(() => {
p { p {
display: grid; display: grid;
padding: var(--spacing-s); padding: var(--spacing-s);
line-height: unset;
cursor: pointer; cursor: pointer;
flex: 1 1 auto;
time { time {
padding: 0 0 var(--spacing-3xs); padding: 0 0 var(--spacing-5xs);
color: var(--color-text-dark); color: var(--color-text-dark);
font-size: var(--font-size-s); font-size: var(--font-size-s);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
@@ -153,7 +161,7 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding-top: var(--spacing-4xs); margin-top: calc(var(--spacing-4xs) * -1);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
} }
} }

View File

@@ -0,0 +1,59 @@
import { createPinia, setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import type { UserAction } from 'n8n-design-system';
import { createComponentRenderer } from '@/__tests__/render';
import WorkflowHistoryContent from '@/components/WorkflowHistory/WorkflowHistoryContent.vue';
import type { WorkflowHistoryActionTypes } from '@/types/workflowHistory';
import { workflowHistoryDataFactory } from '@/stores/__tests__/utils/workflowHistoryTestUtils';
const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download'];
const actions: UserAction[] = actionTypes.map((value) => ({
label: value,
disabled: false,
value,
}));
const renderComponent = createComponentRenderer(WorkflowHistoryContent);
let pinia: ReturnType<typeof createPinia>;
describe('WorkflowHistoryContent', () => {
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
});
it('should use the list item component to render version data', () => {
const workflowVersion = workflowHistoryDataFactory();
const { getByTestId } = renderComponent({
pinia,
props: {
workflow: null,
workflowVersion,
actions,
},
});
expect(getByTestId('workflow-history-list-item')).toBeInTheDocument();
});
test.each(actionTypes)('should emit %s event', async (action) => {
const workflowVersion = workflowHistoryDataFactory();
const { getByTestId, emitted } = renderComponent({
pinia,
props: {
workflow: null,
workflowVersion,
actions,
},
});
await userEvent.click(getByTestId('action-toggle-button'));
expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument();
await userEvent.click(getByTestId(`action-${action}`));
expect(emitted().action).toEqual([
[{ action, id: workflowVersion.versionId, data: { formattedCreatedAt: expect.any(String) } }],
]);
});
});

View File

@@ -2,6 +2,7 @@ import { within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { UserAction } from 'n8n-design-system';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import WorkflowHistoryList from '@/components/WorkflowHistory/WorkflowHistoryList.vue'; import WorkflowHistoryList from '@/components/WorkflowHistory/WorkflowHistoryList.vue';
import type { WorkflowHistoryActionTypes } from '@/types/workflowHistory'; import type { WorkflowHistoryActionTypes } from '@/types/workflowHistory';
@@ -18,6 +19,11 @@ vi.stubGlobal(
); );
const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download']; const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download'];
const actions: UserAction[] = actionTypes.map((value) => ({
label: value,
disabled: false,
value,
}));
const renderComponent = createComponentRenderer(WorkflowHistoryList); const renderComponent = createComponentRenderer(WorkflowHistoryList);
@@ -38,7 +44,7 @@ describe('WorkflowHistoryList', () => {
pinia, pinia,
props: { props: {
items: [], items: [],
actionTypes, actions,
activeItem: null, activeItem: null,
requestNumberOfItems: 20, requestNumberOfItems: 20,
lastReceivedItemsLength: 0, lastReceivedItemsLength: 0,
@@ -55,7 +61,7 @@ describe('WorkflowHistoryList', () => {
pinia, pinia,
props: { props: {
items: [], items: [],
actionTypes, actions,
activeItem: null, activeItem: null,
requestNumberOfItems: 20, requestNumberOfItems: 20,
lastReceivedItemsLength: 0, lastReceivedItemsLength: 0,
@@ -76,7 +82,7 @@ describe('WorkflowHistoryList', () => {
pinia, pinia,
props: { props: {
items, items,
actionTypes, actions,
activeItem: null, activeItem: null,
requestNumberOfItems: 20, requestNumberOfItems: 20,
lastReceivedItemsLength: 20, lastReceivedItemsLength: 20,
@@ -108,7 +114,7 @@ describe('WorkflowHistoryList', () => {
pinia, pinia,
props: { props: {
items, items,
actionTypes, actions,
activeItem: items[0], activeItem: items[0],
requestNumberOfItems: 20, requestNumberOfItems: 20,
lastReceivedItemsLength: 20, lastReceivedItemsLength: 20,
@@ -126,7 +132,7 @@ describe('WorkflowHistoryList', () => {
pinia, pinia,
props: { props: {
items, items,
actionTypes, actions,
activeItem: null, activeItem: null,
requestNumberOfItems: 20, requestNumberOfItems: 20,
lastReceivedItemsLength: 20, lastReceivedItemsLength: 20,
@@ -159,7 +165,7 @@ describe('WorkflowHistoryList', () => {
pinia, pinia,
props: { props: {
items, items,
actionTypes, actions,
activeItem: items[0], activeItem: items[0],
requestNumberOfItems: 20, requestNumberOfItems: 20,
lastReceivedItemsLength: 20, lastReceivedItemsLength: 20,

View File

@@ -36,10 +36,10 @@ describe('WorkflowHistoryListItem', () => {
}, },
}); });
await userEvent.hover(container.querySelector('.el-tooltip__trigger')); await userEvent.hover(container.querySelector('.el-tooltip__trigger')!);
expect(queryByRole('tooltip')).not.toBeInTheDocument(); expect(queryByRole('tooltip')).not.toBeInTheDocument();
await userEvent.click(container.querySelector('p')); await userEvent.click(container.querySelector('p')!);
expect(emitted().preview).toEqual([ expect(emitted().preview).toEqual([
[expect.objectContaining({ id: item.versionId, event: expect.any(MouseEvent) })], [expect.objectContaining({ id: item.versionId, event: expect.any(MouseEvent) })],
]); ]);
@@ -61,7 +61,7 @@ describe('WorkflowHistoryListItem', () => {
}, },
}); });
const authorsTag = container.querySelector('.el-tooltip__trigger'); const authorsTag = container.querySelector('.el-tooltip__trigger')!;
expect(authorsTag).toHaveTextContent(`${authors[0]} + ${authors.length - 1}`); expect(authorsTag).toHaveTextContent(`${authors[0]} + ${authors.length - 1}`);
await userEvent.hover(authorsTag); await userEvent.hover(authorsTag);
expect(getByRole('tooltip')).toBeInTheDocument(); expect(getByRole('tooltip')).toBeInTheDocument();

View File

@@ -189,6 +189,11 @@ export default defineComponent({
this.loadExecution(); this.loadExecution();
} }
}, },
workflow() {
if (this.mode === 'workflow' && this.workflow) {
this.loadWorkflow();
}
},
}, },
mounted() { mounted() {
window.addEventListener('message', this.receiveMessage); window.addEventListener('message', this.receiveMessage);

View File

@@ -1847,6 +1847,10 @@
"workflowSettings.timeoutWorkflow": "Timeout Workflow", "workflowSettings.timeoutWorkflow": "Timeout Workflow",
"workflowSettings.timezone": "Timezone", "workflowSettings.timezone": "Timezone",
"workflowHistory.title": "Version History", "workflowHistory.title": "Version History",
"workflowHistory.content.title": "Version",
"workflowHistory.content.editedBy": "Edited by",
"workflowHistory.content.versionId": "Version ID",
"workflowHistory.content.actions": "Actions",
"workflowHistory.item.id": "ID: {id}", "workflowHistory.item.id": "ID: {id}",
"workflowHistory.item.createdAt": "{date} at {time}", "workflowHistory.item.createdAt": "{date} at {time}",
"workflowHistory.item.actions.restore": "Restore this version", "workflowHistory.item.actions.restore": "Restore this version",

View File

@@ -29,23 +29,19 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
workflowId: string, workflowId: string,
queryParams: WorkflowHistoryRequestParams, queryParams: WorkflowHistoryRequestParams,
): Promise<WorkflowHistory[]> => ): Promise<WorkflowHistory[]> =>
whApi whApi.getWorkflowHistory(rootStore.getRestApiContext, workflowId, queryParams);
.getWorkflowHistory(rootStore.getRestApiContext, workflowId, queryParams)
.catch((error) => {
console.error(error);
return [] as WorkflowHistory[];
});
const getWorkflowVersion = async ( const getWorkflowVersion = async (
workflowId: string, workflowId: string,
versionId: string, versionId: string,
): Promise<WorkflowVersion | null> => ): Promise<WorkflowVersion | null> =>
whApi.getWorkflowVersion(rootStore.getRestApiContext, workflowId, versionId).catch((error) => { whApi.getWorkflowVersion(rootStore.getRestApiContext, workflowId, versionId);
console.error(error);
return null;
});
const downloadVersion = async (workflowId: string, workflowVersionId: WorkflowVersionId) => { const downloadVersion = async (
workflowId: string,
workflowVersionId: WorkflowVersionId,
data: { formattedCreatedAt: string },
) => {
const [workflow, workflowVersion] = await Promise.all([ const [workflow, workflowVersion] = await Promise.all([
workflowsStore.fetchWorkflow(workflowId), workflowsStore.fetchWorkflow(workflowId),
getWorkflowVersion(workflowId, workflowVersionId), getWorkflowVersion(workflowId, workflowVersionId),
@@ -55,7 +51,7 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
const blob = new Blob([JSON.stringify({ ...workflow, nodes, connections }, null, 2)], { const blob = new Blob([JSON.stringify({ ...workflow, nodes, connections }, null, 2)], {
type: 'application/json;charset=utf-8', type: 'application/json;charset=utf-8',
}); });
saveAs(blob, `${workflow.name}-${workflowVersionId}.json`); saveAs(blob, `${workflow.name}(${data.formattedCreatedAt}).json`);
} }
}; };

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeMount, ref, watchEffect, computed } from 'vue'; import { onBeforeMount, ref, watchEffect, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb, UserAction } from '@/Interface';
import { VIEWS, WORKFLOW_HISTORY_VERSION_RESTORE } from '@/constants'; import { VIEWS, WORKFLOW_HISTORY_VERSION_RESTORE } from '@/constants';
import { useI18n, useToast } from '@/composables'; import { useI18n, useToast } from '@/composables';
import type { import type {
@@ -46,6 +46,7 @@ const workflowHistoryStore = useWorkflowHistoryStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const canRender = ref(true);
const isListLoading = ref(true); const isListLoading = ref(true);
const requestNumberOfItems = ref(20); const requestNumberOfItems = ref(20);
const lastReceivedItemsLength = ref(0); const lastReceivedItemsLength = ref(0);
@@ -58,16 +59,13 @@ const editorRoute = computed(() => ({
const activeWorkflow = ref<IWorkflowDb | null>(null); const activeWorkflow = ref<IWorkflowDb | null>(null);
const workflowHistory = ref<WorkflowHistory[]>([]); const workflowHistory = ref<WorkflowHistory[]>([]);
const activeWorkflowVersion = ref<WorkflowVersion | null>(null); const activeWorkflowVersion = ref<WorkflowVersion | null>(null);
const activeWorkflowVersionPreview = computed<IWorkflowDb | null>(() => { const actions = computed<UserAction[]>(() =>
if (activeWorkflowVersion.value && activeWorkflow.value) { workflowHistoryActionTypes.map((value) => ({
return { label: i18n.baseText(`workflowHistory.item.actions.${value}`),
...activeWorkflow.value, disabled: false,
nodes: activeWorkflowVersion.value.nodes, value,
connections: activeWorkflowVersion.value.connections, })),
}; );
}
return null;
});
const loadMore = async (queryParams: WorkflowHistoryRequestParams) => { const loadMore = async (queryParams: WorkflowHistoryRequestParams) => {
const history = await workflowHistoryStore.getWorkflowHistory( const history = await workflowHistoryStore.getWorkflowHistory(
@@ -75,25 +73,30 @@ const loadMore = async (queryParams: WorkflowHistoryRequestParams) => {
queryParams, queryParams,
); );
lastReceivedItemsLength.value = history.length; lastReceivedItemsLength.value = history.length;
workflowHistory.value.push(...history); workflowHistory.value = workflowHistory.value.concat(history);
}; };
onBeforeMount(async () => { onBeforeMount(async () => {
const [workflow] = await Promise.all([ try {
workflowsStore.fetchWorkflow(route.params.workflowId), const [workflow] = await Promise.all([
loadMore({ take: requestNumberOfItems.value }), workflowsStore.fetchWorkflow(route.params.workflowId),
]); loadMore({ take: requestNumberOfItems.value }),
activeWorkflow.value = workflow; ]);
isListLoading.value = false; activeWorkflow.value = workflow;
isListLoading.value = false;
if (!route.params.versionId && workflowHistory.value.length) { if (!route.params.versionId && workflowHistory.value.length) {
await router.replace({ await router.replace({
name: VIEWS.WORKFLOW_HISTORY, name: VIEWS.WORKFLOW_HISTORY,
params: { params: {
workflowId: route.params.workflowId, workflowId: route.params.workflowId,
versionId: workflowHistory.value[0].versionId, versionId: workflowHistory.value[0].versionId,
}, },
}); });
}
} catch (error) {
canRender.value = false;
toast.showError(error, i18n.baseText('workflowHistory.title'));
} }
}); });
@@ -174,7 +177,7 @@ const onAction = async ({
openInNewTab(id); openInNewTab(id);
break; break;
case WORKFLOW_HISTORY_ACTIONS.DOWNLOAD: case WORKFLOW_HISTORY_ACTIONS.DOWNLOAD:
await workflowHistoryStore.downloadVersion(route.params.workflowId, id); await workflowHistoryStore.downloadVersion(route.params.workflowId, id, data);
break; break;
case WORKFLOW_HISTORY_ACTIONS.CLONE: case WORKFLOW_HISTORY_ACTIONS.CLONE:
await workflowHistoryStore.cloneIntoNewWorkflow(route.params.workflowId, id, data); await workflowHistoryStore.cloneIntoNewWorkflow(route.params.workflowId, id, data);
@@ -194,6 +197,10 @@ const onAction = async ({
id, id,
modalAction === WorkflowHistoryVersionRestoreModalActions.deactivateAndRestore, modalAction === WorkflowHistoryVersionRestoreModalActions.deactivateAndRestore,
); );
const history = await workflowHistoryStore.getWorkflowHistory(route.params.workflowId, {
take: 1,
});
workflowHistory.value = history.concat(workflowHistory.value);
toast.showMessage({ toast.showMessage({
title: i18n.baseText('workflowHistory.action.restore.success.title'), title: i18n.baseText('workflowHistory.action.restore.success.title'),
type: 'success', type: 'success',
@@ -231,13 +238,26 @@ const onUpgrade = () => {
}; };
watchEffect(async () => { watchEffect(async () => {
if (route.params.versionId) { if (!route.params.versionId) {
const [workflow, workflowVersion] = await Promise.all([ return;
workflowsStore.fetchWorkflow(route.params.workflowId), }
workflowHistoryStore.getWorkflowVersion(route.params.workflowId, route.params.versionId), try {
]); activeWorkflowVersion.value = await workflowHistoryStore.getWorkflowVersion(
activeWorkflow.value = workflow; route.params.workflowId,
activeWorkflowVersion.value = workflowVersion; route.params.versionId,
);
} catch (error) {
toast.showError(
new Error(`${error.message} "${route.params.versionId}"&nbsp;`),
i18n.baseText('workflowHistory.title'),
);
}
try {
activeWorkflow.value = await workflowsStore.fetchWorkflow(route.params.workflowId);
} catch (error) {
canRender.value = false;
toast.showError(error, i18n.baseText('workflowHistory.title'));
} }
}); });
</script> </script>
@@ -254,15 +274,13 @@ watchEffect(async () => {
<n8n-button type="tertiary" icon="times" size="small" text square /> <n8n-button type="tertiary" icon="times" size="small" text square />
</router-link> </router-link>
</div> </div>
<div :class="$style.contentComponentWrapper">
<workflow-history-content :workflow-version="activeWorkflowVersionPreview" />
</div>
<div :class="$style.listComponentWrapper"> <div :class="$style.listComponentWrapper">
<workflow-history-list <workflow-history-list
v-if="canRender"
:items="workflowHistory" :items="workflowHistory"
:lastReceivedItemsLength="lastReceivedItemsLength" :lastReceivedItemsLength="lastReceivedItemsLength"
:activeItem="activeWorkflowVersion" :activeItem="activeWorkflowVersion"
:actionTypes="workflowHistoryActionTypes" :actions="actions"
:requestNumberOfItems="requestNumberOfItems" :requestNumberOfItems="requestNumberOfItems"
:shouldUpgrade="workflowHistoryStore.shouldUpgrade" :shouldUpgrade="workflowHistoryStore.shouldUpgrade"
:evaluatedPruneTime="workflowHistoryStore.evaluatedPruneTime" :evaluatedPruneTime="workflowHistoryStore.evaluatedPruneTime"
@@ -273,6 +291,16 @@ watchEffect(async () => {
@upgrade="onUpgrade" @upgrade="onUpgrade"
/> />
</div> </div>
<div :class="$style.contentComponentWrapper">
<workflow-history-content
v-if="canRender"
:workflow="activeWorkflow"
:workflow-version="activeWorkflowVersion"
:actions="actions"
:isListLoading="isListLoading"
@action="onAction"
/>
</div>
</div> </div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
@@ -308,13 +336,11 @@ watchEffect(async () => {
.contentComponentWrapper { .contentComponentWrapper {
grid-area: content; grid-area: content;
position: relative; position: relative;
z-index: 1;
} }
.listComponentWrapper { .listComponentWrapper {
grid-area: list; grid-area: list;
position: relative; position: relative;
z-index: 2;
&::before { &::before {
content: ''; content: '';

View File

@@ -9,12 +9,14 @@ import { createComponentRenderer } from '@/__tests__/render';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import WorkflowHistoryPage from '@/views/WorkflowHistory.vue'; import WorkflowHistoryPage from '@/views/WorkflowHistory.vue';
import { useWorkflowHistoryStore } from '@/stores/workflowHistory.store'; import { useWorkflowHistoryStore } from '@/stores/workflowHistory.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { STORES, VIEWS } from '@/constants'; import { STORES, VIEWS } from '@/constants';
import { import {
workflowHistoryDataFactory, workflowHistoryDataFactory,
workflowVersionDataFactory, workflowVersionDataFactory,
} from '@/stores/__tests__/utils/workflowHistoryTestUtils'; } from '@/stores/__tests__/utils/workflowHistoryTestUtils';
import type { WorkflowVersion } from '@/types/workflowHistory'; import type { WorkflowVersion } from '@/types/workflowHistory';
import type { IWorkflowDb } from '@/Interface';
vi.mock('vue-router', () => { vi.mock('vue-router', () => {
const params = {}; const params = {};
@@ -63,6 +65,7 @@ let pinia: ReturnType<typeof createTestingPinia>;
let router: ReturnType<typeof useRouter>; let router: ReturnType<typeof useRouter>;
let route: ReturnType<typeof useRoute>; let route: ReturnType<typeof useRoute>;
let workflowHistoryStore: ReturnType<typeof useWorkflowHistoryStore>; let workflowHistoryStore: ReturnType<typeof useWorkflowHistoryStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let windowOpenSpy: SpyInstance; let windowOpenSpy: SpyInstance;
describe('WorkflowHistory', () => { describe('WorkflowHistory', () => {
@@ -73,9 +76,11 @@ describe('WorkflowHistory', () => {
}, },
}); });
workflowHistoryStore = useWorkflowHistoryStore(); workflowHistoryStore = useWorkflowHistoryStore();
workflowsStore = useWorkflowsStore();
route = useRoute(); route = useRoute();
router = useRouter(); router = useRouter();
vi.spyOn(workflowsStore, 'fetchWorkflow').mockResolvedValue({} as IWorkflowDb);
vi.spyOn(workflowHistoryStore, 'getWorkflowHistory').mockResolvedValue(historyData); vi.spyOn(workflowHistoryStore, 'getWorkflowHistory').mockResolvedValue(historyData);
vi.spyOn(workflowHistoryStore, 'getWorkflowVersion').mockResolvedValue(versionData); vi.spyOn(workflowHistoryStore, 'getWorkflowVersion').mockResolvedValue(versionData);
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);