mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
feat(editor): Add descriptive header to projects /workflow (#11203)
This commit is contained in:
parent
f6c8890a80
commit
5d19e8f2b4
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Reference in a new issue