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:
Csaba Tuncsik
2023-10-04 16:45:18 +02:00
committed by GitHub
parent b59b9086d7
commit 4bc9164032
15 changed files with 334 additions and 113 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;

View File

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

View File

@@ -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) => ({