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:
Csaba Tuncsik
2023-09-29 17:48:36 +02:00
committed by GitHub
parent ec0379378e
commit d1b6c7fd79
14 changed files with 1019 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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