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:
@@ -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 }]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user