mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-13 13:57:29 -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>
|
<style lang="scss" module>
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
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 { createComponentRenderer } from '@/__tests__/render';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import type router from 'vue-router';
|
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) => {
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
const { RouterLink } = await importOriginal<typeof router>();
|
const { RouterLink } = await importOriginal<typeof router>();
|
||||||
|
@ -18,7 +23,8 @@ const renderComponent = createComponentRenderer(ResourcesListLayout);
|
||||||
|
|
||||||
describe('ResourcesListLayout', () => {
|
describe('ResourcesListLayout', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia());
|
const pinia = createTestingPinia();
|
||||||
|
setActivePinia(pinia);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render loading skeleton', () => {
|
it('should render loading skeleton', () => {
|
||||||
|
@ -30,4 +36,46 @@ describe('ResourcesListLayout', () => {
|
||||||
|
|
||||||
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
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 { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue';
|
||||||
import type { PropType } 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 PageViewLayout from '@/components/layouts/PageViewLayout.vue';
|
||||||
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
|
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
|
||||||
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
|
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
|
||||||
|
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import type { DatatableColumn } from 'n8n-design-system';
|
import type { DatatableColumn } from 'n8n-design-system';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useRoute } from 'vue-router';
|
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
|
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
@ -44,6 +46,7 @@ export default defineComponent({
|
||||||
PageViewLayout,
|
PageViewLayout,
|
||||||
PageViewLayoutList,
|
PageViewLayoutList,
|
||||||
ResourceFiltersDropdown,
|
ResourceFiltersDropdown,
|
||||||
|
ResourceListHeader,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
resourceKey: {
|
resourceKey: {
|
||||||
|
@ -113,6 +116,7 @@ export default defineComponent({
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const sortBy = ref(props.sortOptions[0]);
|
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 {
|
return {
|
||||||
i18n,
|
i18n,
|
||||||
search,
|
search,
|
||||||
usersStore,
|
usersStore,
|
||||||
|
projectsStore,
|
||||||
filterKeys,
|
filterKeys,
|
||||||
currentPage,
|
currentPage,
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
|
@ -362,6 +387,8 @@ export default defineComponent({
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
headerIcon,
|
||||||
|
projectName,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -369,7 +396,14 @@ export default defineComponent({
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageViewLayout>
|
<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">
|
<div v-if="loading" class="resource-list-loading">
|
||||||
<n8n-loading :rows="25" :shrink-last="false" />
|
<n8n-loading :rows="25" :shrink-last="false" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type { IUser } from '@/Interface';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
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 { useToast } from '@/composables/useToast';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
|
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 { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
|
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
|
||||||
|
|
||||||
type FormDataDiff = {
|
type FormDataDiff = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -247,6 +248,26 @@ watch(
|
||||||
{ immediate: true },
|
{ 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 () => {
|
onBeforeMount(async () => {
|
||||||
await usersStore.fetchUsers();
|
await usersStore.fetchUsers();
|
||||||
});
|
});
|
||||||
|
@ -260,6 +281,11 @@ onMounted(() => {
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.projectSettings">
|
<div :class="$style.projectSettings">
|
||||||
<div :class="$style.header">
|
<div :class="$style.header">
|
||||||
|
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
|
||||||
|
<template #title>
|
||||||
|
{{ projectName }}
|
||||||
|
</template>
|
||||||
|
</ResourceListHeader>
|
||||||
<ProjectTabs />
|
<ProjectTabs />
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="onSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
|
|
|
@ -53,17 +53,17 @@ describe('WorkflowsView', () => {
|
||||||
|
|
||||||
describe('should show empty state', () => {
|
describe('should show empty state', () => {
|
||||||
it('for non setup user', () => {
|
it('for non setup user', () => {
|
||||||
const { getByRole } = renderComponent({ pinia: createTestingPinia({ initialState }) });
|
const { getByText } = renderComponent({ pinia: createTestingPinia({ initialState }) });
|
||||||
expect(getByRole('heading').textContent).toBe('👋 Welcome!');
|
expect(getByText('👋 Welcome!')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('for currentUser user', () => {
|
it('for currentUser user', () => {
|
||||||
const pinia = createTestingPinia({ initialState });
|
const pinia = createTestingPinia({ initialState });
|
||||||
const userStore = mockedStore(useUsersStore);
|
const userStore = mockedStore(useUsersStore);
|
||||||
userStore.currentUser = { firstName: 'John' } as IUser;
|
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', () => {
|
describe('when onboardingExperiment -> False', () => {
|
||||||
|
|
Loading…
Reference in a new issue