mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
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:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
@command="onCommand"
|
@command="onCommand"
|
||||||
@visible-change="onVisibleChange"
|
@visible-change="onVisibleChange"
|
||||||
>
|
>
|
||||||
|
<slot>
|
||||||
<span :class="{ [$style.button]: true, [$style[theme]]: !!theme }">
|
<span :class="{ [$style.button]: true, [$style[theme]]: !!theme }">
|
||||||
<n8n-icon
|
<n8n-icon
|
||||||
:icon="iconOrientation === 'horizontal' ? 'ellipsis-h' : 'ellipsis-v'"
|
:icon="iconOrientation === 'horizontal' ? 'ellipsis-h' : 'ellipsis-v'"
|
||||||
:size="iconSize"
|
: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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<slot :formattedCreatedAt="formattedCreatedAt">
|
||||||
<p @click="onItemClick">
|
<p @click="onItemClick">
|
||||||
<time :datetime="item.createdAt">{{ formattedCreatedAt }}</time>
|
<time :datetime="item.createdAt">{{ formattedCreatedAt }}</time>
|
||||||
<n8n-tooltip placement="right-end" :disabled="authors.size < 2 && !isAuthorElementTruncated">
|
<n8n-tooltip
|
||||||
|
placement="right-end"
|
||||||
|
:disabled="authors.size < 2 && !isAuthorElementTruncated"
|
||||||
|
>
|
||||||
<template #content>{{ props.item.authors }}</template>
|
<template #content>{{ props.item.authors }}</template>
|
||||||
<span ref="authorElement">{{ authors.label }}</span>
|
<span ref="authorElement">{{ authors.label }}</span>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
<data :value="item.versionId">{{ idLabel }}</data>
|
<data :value="item.versionId">{{ idLabel }}</data>
|
||||||
</p>
|
</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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) } }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +73,11 @@ 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 () => {
|
||||||
|
try {
|
||||||
const [workflow] = await Promise.all([
|
const [workflow] = await Promise.all([
|
||||||
workflowsStore.fetchWorkflow(route.params.workflowId),
|
workflowsStore.fetchWorkflow(route.params.workflowId),
|
||||||
loadMore({ take: requestNumberOfItems.value }),
|
loadMore({ take: requestNumberOfItems.value }),
|
||||||
@@ -95,6 +94,10 @@ onBeforeMount(async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
canRender.value = false;
|
||||||
|
toast.showError(error, i18n.baseText('workflowHistory.title'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const openInNewTab = (id: WorkflowVersionId) => {
|
const openInNewTab = (id: WorkflowVersionId) => {
|
||||||
@@ -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}" `),
|
||||||
|
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: '';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user