feat(editor): Add descriptive header to projects /workflow (#11203)

This commit is contained in:
Raúl Gómez Morales 2024-10-31 12:43:25 +01:00 committed by GitHub
parent f6c8890a80
commit 5d19e8f2b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 182 additions and 11 deletions

View file

@ -27,11 +27,11 @@ export default defineComponent({
<style lang="scss" module>
.wrapper {
display: grid;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: 1280px;
grid-template-rows: auto 1fr;
box-sizing: border-box;
align-content: start;
padding: var(--spacing-l) var(--spacing-2xl) 0;

View file

@ -0,0 +1,20 @@
import { createComponentRenderer } from '@/__tests__/render';
import ResourceListHeader from './ResourceListHeader.vue';
const renderComponent = createComponentRenderer(ResourceListHeader);
describe('WorkflowHeader', () => {
it('should render icon prop', () => {
const icon = 'home';
const { container } = renderComponent({ props: { icon } });
expect(container.querySelector(`.fa-${icon}`)).toBeVisible();
});
test.each([
['title', 'title slot'],
['subtitle', 'subtitle slot'],
['actions', 'actions slot'],
])('should render "%s" slot', (slot, content) => {
const { getByText } = renderComponent({ props: { icon: 'home' }, slots: { [slot]: content } });
expect(getByText(content)).toBeVisible();
});
});

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import { N8nHeading, N8nText, N8nIcon } from 'n8n-design-system';
defineProps<{ icon: string }>();
</script>
<template>
<div :class="[$style.workflowHeader]">
<div :class="[$style.icon]">
<N8nIcon :icon color="text-light"></N8nIcon>
</div>
<div>
<N8nHeading bold tag="h2" size="xlarge">
<slot name="title" />
</N8nHeading>
<N8nText v-if="$slots.subtitle" size="small" color="text-light">
<slot name="subtitle" />
</N8nText>
</div>
<div v-if="$slots.actions" :class="[$style.actions]">
<slot name="actions"></slot>
</div>
</div>
</template>
<style lang="scss" module>
.workflowHeader {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: var(--spacing-m);
min-height: 64px;
}
.icon {
border: 1px solid var(--color-foreground-light);
padding: 6px;
border-radius: var(--border-radius-base);
}
.actions {
margin-left: auto;
}
</style>

View file

@ -1,7 +1,12 @@
import { setActivePinia, createPinia } from 'pinia';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { within, waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import type router from 'vue-router';
import { mockedStore } from '@/__tests__/utils';
import { useProjectsStore } from '@/stores/projects.store';
import { type Project, ProjectTypes } from '@/types/projects.types';
vi.mock('vue-router', async (importOriginal) => {
const { RouterLink } = await importOriginal<typeof router>();
@ -18,7 +23,8 @@ const renderComponent = createComponentRenderer(ResourcesListLayout);
describe('ResourcesListLayout', () => {
beforeEach(() => {
setActivePinia(createPinia());
const pinia = createTestingPinia();
setActivePinia(pinia);
});
it('should render loading skeleton', () => {
@ -30,4 +36,46 @@ describe('ResourcesListLayout', () => {
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
});
describe('header', () => {
it('should render the correct icon', async () => {
const projects = mockedStore(useProjectsStore);
const { getByTestId } = renderComponent();
expect(getByTestId('list-layout-header').querySelector('.fa-home')).toBeVisible();
projects.currentProject = { type: ProjectTypes.Personal } as Project;
await waitFor(() =>
expect(getByTestId('list-layout-header').querySelector('.fa-user')).toBeVisible(),
);
const projectName = 'My Project';
projects.currentProject = { name: projectName } as Project;
await waitFor(() =>
expect(getByTestId('list-layout-header').querySelector('.fa-layer-group')).toBeVisible(),
);
});
it('should render the correct title', async () => {
const projects = mockedStore(useProjectsStore);
const { getByTestId } = renderComponent();
expect(within(getByTestId('list-layout-header')).getByText('Home')).toBeVisible();
projects.currentProject = { type: ProjectTypes.Personal } as Project;
await waitFor(() =>
expect(within(getByTestId('list-layout-header')).getByText('Personal')).toBeVisible(),
);
const projectName = 'My Project';
projects.currentProject = { name: projectName } as Project;
await waitFor(() =>
expect(within(getByTestId('list-layout-header')).getByText(projectName)).toBeVisible(),
);
});
});
});

View file

@ -2,16 +2,18 @@
import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue';
import type { PropType } from 'vue';
import type { ProjectSharingData } from '@/types/projects.types';
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
import { useUsersStore } from '@/stores/users.store';
import type { DatatableColumn } from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce';
import { useTelemetry } from '@/composables/useTelemetry';
import { useRoute } from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store';
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
import type { BaseTextKey } from '@/plugins/i18n';
@ -44,6 +46,7 @@ export default defineComponent({
PageViewLayout,
PageViewLayoutList,
ResourceFiltersDropdown,
ResourceListHeader,
},
props: {
resourceKey: {
@ -113,6 +116,7 @@ export default defineComponent({
const i18n = useI18n();
const { callDebounced } = useDebounce();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const sortBy = ref(props.sortOptions[0]);
@ -339,10 +343,31 @@ export default defineComponent({
}
});
const headerIcon = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return 'user';
} else if (projectsStore.currentProject?.name) {
return 'layer-group';
} else {
return 'home';
}
});
const projectName = computed(() => {
if (!projectsStore.currentProject) {
return i18n.baseText('projects.menu.home');
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
} else {
return projectsStore.currentProject.name;
}
});
return {
i18n,
search,
usersStore,
projectsStore,
filterKeys,
currentPage,
rowsPerPage,
@ -362,6 +387,8 @@ export default defineComponent({
setCurrentPage,
setRowsPerPage,
onSearch,
headerIcon,
projectName,
};
},
});
@ -369,7 +396,14 @@ export default defineComponent({
<template>
<PageViewLayout>
<template #header> <slot name="header" /> </template>
<template #header>
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
<template #title>
{{ projectName }}
</template>
</ResourceListHeader>
<slot name="header" />
</template>
<div v-if="loading" class="resource-list-loading">
<n8n-loading :rows="25" :shrink-last="false" />
</div>

View file

@ -8,7 +8,7 @@ import type { IUser } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import type { Project, ProjectRelation } from '@/types/projects.types';
import { type Project, type ProjectRelation, ProjectTypes } from '@/types/projects.types';
import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants';
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
@ -18,6 +18,7 @@ import type { ProjectRole } from '@/types/roles.types';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
type FormDataDiff = {
name?: string;
@ -247,6 +248,26 @@ watch(
{ immediate: true },
);
const headerIcon = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return 'user';
} else if (projectsStore.currentProject?.name) {
return 'layer-group';
} else {
return 'home';
}
});
const projectName = computed(() => {
if (!projectsStore.currentProject) {
return locale.baseText('projects.menu.home');
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
return locale.baseText('projects.menu.personal');
} else {
return projectsStore.currentProject.name;
}
});
onBeforeMount(async () => {
await usersStore.fetchUsers();
});
@ -260,6 +281,11 @@ onMounted(() => {
<template>
<div :class="$style.projectSettings">
<div :class="$style.header">
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
<template #title>
{{ projectName }}
</template>
</ResourceListHeader>
<ProjectTabs />
</div>
<form @submit.prevent="onSubmit">

View file

@ -53,17 +53,17 @@ describe('WorkflowsView', () => {
describe('should show empty state', () => {
it('for non setup user', () => {
const { getByRole } = renderComponent({ pinia: createTestingPinia({ initialState }) });
expect(getByRole('heading').textContent).toBe('👋 Welcome!');
const { getByText } = renderComponent({ pinia: createTestingPinia({ initialState }) });
expect(getByText('👋 Welcome!')).toBeVisible();
});
it('for currentUser user', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUser = { firstName: 'John' } as IUser;
const { getByRole } = renderComponent({ pinia });
const { getByText } = renderComponent({ pinia });
expect(getByRole('heading').textContent).toBe('👋 Welcome John!');
expect(getByText('👋 Welcome John!')).toBeVisible();
});
describe('when onboardingExperiment -> False', () => {