feat(editor): Workflow history [WIP]- Add workflow history opening button to main header component (no-changelog) (#7310)
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
@@ -101,6 +101,13 @@
|
||||
data-test-id="workflow-save-button"
|
||||
@click="onSaveButtonClick"
|
||||
/>
|
||||
<router-link
|
||||
v-if="isWorkflowHistoryFeatureEnabled"
|
||||
:to="workflowHistoryRoute"
|
||||
:class="$style.workflowHistoryButton"
|
||||
>
|
||||
<n8n-icon icon="history" size="medium" />
|
||||
</router-link>
|
||||
<div :class="$style.workflowMenuContainer">
|
||||
<input
|
||||
:class="$style.hiddenInput"
|
||||
@@ -335,6 +342,19 @@ export default defineComponent({
|
||||
|
||||
return actions;
|
||||
},
|
||||
isWorkflowHistoryFeatureEnabled(): boolean {
|
||||
return this.settingsStore.isEnterpriseFeatureEnabled(
|
||||
EnterpriseEditionFeature.WorkflowHistory,
|
||||
);
|
||||
},
|
||||
workflowHistoryRoute(): { name: string; params: { workflowId: string } } {
|
||||
return {
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: {
|
||||
workflowId: this.currentWorkflowId,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onSaveButtonClick() {
|
||||
@@ -389,7 +409,7 @@ export default defineComponent({
|
||||
|
||||
const saved = await this.saveCurrentWorkflow({ tags });
|
||||
this.$telemetry.track('User edited workflow tags', {
|
||||
workflow_id: this.currentWorkflowId as string,
|
||||
workflow_id: this.currentWorkflowId,
|
||||
new_tag_count: tags.length,
|
||||
});
|
||||
|
||||
@@ -690,4 +710,9 @@ $--header-spacing: 20px;
|
||||
.disabledShareButton {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.workflowHistoryButton {
|
||||
margin-left: var(--spacing-l);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,11 @@ const props = defineProps<{
|
||||
|
||||
<style module lang="scss">
|
||||
.content {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,21 +10,17 @@ import type {
|
||||
} 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 props = defineProps<{
|
||||
items: WorkflowHistory[];
|
||||
activeItem: WorkflowHistory | null;
|
||||
actionTypes: WorkflowHistoryActionTypes;
|
||||
requestNumberOfItems: number;
|
||||
lastReceivedItemsLength: number;
|
||||
evaluatedPruneTime: number;
|
||||
shouldUpgrade?: boolean;
|
||||
isListLoading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: 'action',
|
||||
@@ -98,7 +94,10 @@ const onItemMounted = ({
|
||||
listElement.value?.scrollTo({ top: offsetTop, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
if (index === props.items.length - 1 && props.items.length >= props.requestNumberOfItems) {
|
||||
if (
|
||||
index === props.items.length - 1 &&
|
||||
props.lastReceivedItemsLength === props.requestNumberOfItems
|
||||
) {
|
||||
observeElement(listElement.value?.children[index] as Element);
|
||||
}
|
||||
};
|
||||
@@ -117,16 +116,28 @@ const onItemMounted = ({
|
||||
@preview="onPreview"
|
||||
@mounted="onItemMounted"
|
||||
/>
|
||||
<li v-if="!props.items.length" :class="$style.empty">
|
||||
<li v-if="!props.items.length && !props.isListLoading" :class="$style.empty">
|
||||
{{ i18n.baseText('workflowHistory.empty') }}
|
||||
<br />
|
||||
{{ i18n.baseText('workflowHistory.hint') }}
|
||||
</li>
|
||||
<li v-if="props.shouldUpgrade && props.maxRetentionPeriod > 0" :class="$style.retention">
|
||||
<li
|
||||
v-if="props.isListLoading"
|
||||
:class="$style.loader"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
:aria-label="i18n.baseText('generic.loading')"
|
||||
>
|
||||
<n8n-loading :rows="3" class="mb-xs" />
|
||||
<n8n-loading :rows="3" class="mb-xs" />
|
||||
<n8n-loading :rows="3" class="mb-xs" />
|
||||
</li>
|
||||
<li v-if="props.shouldUpgrade" :class="$style.retention">
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('workflowHistory.limit', {
|
||||
interpolate: { maxRetentionPeriod: props.maxRetentionPeriod },
|
||||
interpolate: { evaluatedPruneTime: props.evaluatedPruneTime },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
@@ -143,19 +154,12 @@ const onItemMounted = ({
|
||||
|
||||
<style module lang="scss">
|
||||
.list {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
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 {
|
||||
@@ -171,6 +175,10 @@ const onItemMounted = ({
|
||||
line-height: var(--font-line-height-loose);
|
||||
}
|
||||
|
||||
.loader {
|
||||
padding: 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.retention {
|
||||
display: grid;
|
||||
padding: var(--spacing-s);
|
||||
|
||||
@@ -28,6 +28,8 @@ const i18n = useI18n();
|
||||
|
||||
const actionsVisible = ref(false);
|
||||
const itemElement = ref<HTMLElement | null>(null);
|
||||
const authorElement = ref<HTMLElement | null>(null);
|
||||
const isAuthorElementTruncated = ref(false);
|
||||
|
||||
const formattedCreatedAtDate = computed<string>(() => {
|
||||
const currentYear = new Date().getFullYear().toString();
|
||||
@@ -75,6 +77,8 @@ onMounted(() => {
|
||||
offsetTop: itemElement.value?.offsetTop ?? 0,
|
||||
isActive: props.isActive,
|
||||
});
|
||||
isAuthorElementTruncated.value =
|
||||
authorElement.value?.scrollWidth > authorElement.value?.clientWidth;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
@@ -89,9 +93,9 @@ onMounted(() => {
|
||||
>
|
||||
<p @click="onItemClick">
|
||||
<time :datetime="item.createdAt">{{ formattedCreatedAtDate }}</time>
|
||||
<n8n-tooltip placement="right-end" :disabled="authors.size < 2">
|
||||
<n8n-tooltip placement="right-end" :disabled="authors.size < 2 && !isAuthorElementTruncated">
|
||||
<template #content>{{ props.item.authors }}</template>
|
||||
<span>{{ authors.label }}</span>
|
||||
<span ref="authorElement">{{ authors.label }}</span>
|
||||
</n8n-tooltip>
|
||||
<data :value="item.versionId">{{ idLabel }}</data>
|
||||
</p>
|
||||
@@ -128,17 +132,15 @@ onMounted(() => {
|
||||
cursor: pointer;
|
||||
|
||||
time {
|
||||
padding: 0 0 var(--spacing-2xs);
|
||||
padding: 0 0 var(--spacing-3xs);
|
||||
color: var(--color-text-dark);
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
span {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
span,
|
||||
data {
|
||||
justify-self: start;
|
||||
max-width: 160px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -4,7 +4,8 @@ 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';
|
||||
import type { WorkflowHistoryActionTypes } from '@/types/workflowHistory';
|
||||
import { workflowHistoryDataFactory } from '@/stores/__tests__/utils/workflowHistoryTestUtils';
|
||||
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
@@ -16,14 +17,6 @@ vi.stubGlobal(
|
||||
})),
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -40,34 +33,59 @@ describe('WorkflowHistoryList', () => {
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
it('should render empty list', () => {
|
||||
const { getByText } = renderComponent({
|
||||
it('should render empty list when not loading and no items', () => {
|
||||
const { getByText, queryByRole } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
items: [],
|
||||
actionTypes,
|
||||
activeItem: null,
|
||||
requestNumberOfItems: 20,
|
||||
lastReceivedItemsLength: 0,
|
||||
evaluatedPruneTime: -1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(getByText(/No versions yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loader but no empty list message when loading', () => {
|
||||
const { queryByText, getByRole } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
items: [],
|
||||
actionTypes,
|
||||
activeItem: null,
|
||||
requestNumberOfItems: 20,
|
||||
lastReceivedItemsLength: 0,
|
||||
evaluatedPruneTime: -1,
|
||||
isListLoading: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByRole('status')).toBeInTheDocument();
|
||||
expect(queryByText(/No versions yet/)).not.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({
|
||||
const { getAllByTestId, emitted, queryByRole } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
items,
|
||||
actionTypes,
|
||||
activeItem: null,
|
||||
requestNumberOfItems: 20,
|
||||
lastReceivedItemsLength: 20,
|
||||
evaluatedPruneTime: -1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByRole('link', { name: /upgrade/i })).not.toBeInTheDocument();
|
||||
|
||||
const listItems = getAllByTestId('workflow-history-list-item');
|
||||
const listItem = listItems[items.length - 1];
|
||||
await userEvent.click(within(listItem).getByText(/ID: /));
|
||||
@@ -93,6 +111,8 @@ describe('WorkflowHistoryList', () => {
|
||||
actionTypes,
|
||||
activeItem: items[0],
|
||||
requestNumberOfItems: 20,
|
||||
lastReceivedItemsLength: 20,
|
||||
evaluatedPruneTime: -1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,6 +129,8 @@ describe('WorkflowHistoryList', () => {
|
||||
actionTypes,
|
||||
activeItem: null,
|
||||
requestNumberOfItems: 20,
|
||||
lastReceivedItemsLength: 20,
|
||||
evaluatedPruneTime: -1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,4 +143,23 @@ describe('WorkflowHistoryList', () => {
|
||||
await userEvent.click(within(actionsDropdown).getByTestId(`action-${action}`));
|
||||
expect(emitted().action).toEqual([[{ action, id: items[index].versionId }]]);
|
||||
});
|
||||
|
||||
it('should show upgrade message', async () => {
|
||||
const items = Array.from({ length: 5 }, workflowHistoryDataFactory);
|
||||
|
||||
const { getByRole } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
items,
|
||||
actionTypes,
|
||||
activeItem: items[0],
|
||||
requestNumberOfItems: 20,
|
||||
lastReceivedItemsLength: 20,
|
||||
evaluatedPruneTime: -1,
|
||||
shouldUpgrade: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByRole('link', { name: /upgrade/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
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(
|
||||
', ',
|
||||
),
|
||||
});
|
||||
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) => ({
|
||||
|
||||
Reference in New Issue
Block a user