mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
fix(editor): Add Personal project to main navigation (#11161)
This commit is contained in:
parent
5c370c9235
commit
1f441f9752
|
@ -104,7 +104,7 @@ const isItemActive = (item: IMenuItem): boolean => {
|
|||
<N8nTooltip
|
||||
v-else
|
||||
placement="right"
|
||||
:content="item.label"
|
||||
:content="compact ? item.label : ''"
|
||||
:disabled="!compact"
|
||||
:show-after="tooltipDelay"
|
||||
>
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createRouter, createMemoryHistory, useRouter } from 'vue-router';
|
||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||
import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import type { Project } from '@/types/projects.types';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
const push = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => ({
|
||||
push,
|
||||
}),
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useToast', () => {
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
return {
|
||||
useToast: () => ({
|
||||
showMessage,
|
||||
showError,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectsNavigation, {
|
||||
global: {
|
||||
plugins: [
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: { template: '<div>Home</div>' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
let toast: ReturnType<typeof useToast>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||
|
||||
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
|
||||
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
|
||||
|
||||
describe('ProjectsNavigation', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
|
||||
router = useRouter();
|
||||
toast = useToast();
|
||||
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
uiStore = mockedStore(useUIStore);
|
||||
});
|
||||
|
||||
it('should not throw an error', () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
expect(() => {
|
||||
renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not show "Add project" button when conditions are not met', async () => {
|
||||
projectsStore.teamProjectsLimit = 0;
|
||||
projectsStore.hasPermissionToCreateProjects = false;
|
||||
|
||||
const { queryByText } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText('Add project')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Add project" button when conditions met', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
projectsStore.hasPermissionToCreateProjects = true;
|
||||
projectsStore.createProject.mockResolvedValue({
|
||||
id: '1',
|
||||
name: 'My project 1',
|
||||
} as Project);
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Add project')).toBeVisible();
|
||||
await userEvent.click(getByText('Add project'));
|
||||
|
||||
expect(projectsStore.createProject).toHaveBeenCalledWith({
|
||||
name: 'My project',
|
||||
});
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
name: VIEWS.PROJECT_SETTINGS,
|
||||
params: { projectId: '1' },
|
||||
});
|
||||
expect(toast.showMessage).toHaveBeenCalledWith({
|
||||
title: 'Project My project 1 saved successfully',
|
||||
type: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "Add project" button tooltip when project creation limit reached', async () => {
|
||||
projectsStore.teamProjectsLimit = 3;
|
||||
projectsStore.hasPermissionToCreateProjects = true;
|
||||
projectsStore.canCreateProjects = false;
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
planName: 'Free',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Add project')).toBeVisible();
|
||||
await userEvent.hover(getByText('Add project'));
|
||||
|
||||
expect(getByText(/You have reached the Free plan limit of 3/)).toBeVisible();
|
||||
await userEvent.click(getByText('View plans'));
|
||||
|
||||
expect(uiStore.goToUpgrade).toHaveBeenCalledWith('rbac', 'upgrade-rbac');
|
||||
});
|
||||
|
||||
it('should show "Projects" title and projects if the user has access to any team project', async () => {
|
||||
projectsStore.myProjects = [...personalProjects, ...teamProjects];
|
||||
|
||||
const { getByRole, getAllByTestId, getByTestId } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByRole('heading', { level: 3, name: 'Projects' })).toBeVisible();
|
||||
expect(getByTestId('project-personal-menu-item')).toBeVisible();
|
||||
expect(getAllByTestId('project-menu-item')).toHaveLength(teamProjects.length);
|
||||
});
|
||||
|
||||
it('should not show "Projects" title when the menu is collapsed', async () => {
|
||||
projectsStore.myProjects = [...personalProjects, ...teamProjects];
|
||||
|
||||
const { queryByRole } = renderComponent({
|
||||
props: {
|
||||
collapsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -44,6 +44,7 @@ const addProject = computed<IMenuItem>(() => ({
|
|||
const getProjectMenuItem = (project: ProjectListItem) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
icon: 'layer-group',
|
||||
route: {
|
||||
to: {
|
||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||
|
@ -52,8 +53,18 @@ const getProjectMenuItem = (project: ProjectListItem) => ({
|
|||
},
|
||||
});
|
||||
|
||||
const homeClicked = () => {};
|
||||
const projectClicked = () => {};
|
||||
const personalProject = computed<IMenuItem>(() => ({
|
||||
id: projectsStore.personalProject?.id ?? '',
|
||||
label: locale.baseText('projects.menu.personal'),
|
||||
icon: 'user',
|
||||
route: {
|
||||
to: {
|
||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||
params: { projectId: projectsStore.personalProject?.id },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const addProjectClicked = async () => {
|
||||
isCreatingProject.value = true;
|
||||
|
||||
|
@ -107,7 +118,6 @@ onMounted(async () => {
|
|||
<N8nMenuItem
|
||||
:item="home"
|
||||
:compact="props.collapsed"
|
||||
:handle-select="homeClicked"
|
||||
:active-tab="projectsStore.projectNavActiveId"
|
||||
mode="tabs"
|
||||
data-test-id="project-home-menu-item"
|
||||
|
@ -120,7 +130,17 @@ onMounted(async () => {
|
|||
"
|
||||
class="mt-m mb-m"
|
||||
/>
|
||||
<N8nText v-if="!props.collapsed" :class="$style.projectsLabel" tag="h3" bold>
|
||||
<span>{{ locale.baseText('projects.menu.title') }}</span>
|
||||
</N8nText>
|
||||
<ElMenu v-if="displayProjects.length" :collapse="props.collapsed" :class="$style.projectItems">
|
||||
<N8nMenuItem
|
||||
:item="personalProject"
|
||||
:compact="props.collapsed"
|
||||
:active-tab="projectsStore.projectNavActiveId"
|
||||
mode="tabs"
|
||||
data-test-id="project-personal-menu-item"
|
||||
/>
|
||||
<N8nMenuItem
|
||||
v-for="project in displayProjects"
|
||||
:key="project.id"
|
||||
|
@ -129,20 +149,19 @@ onMounted(async () => {
|
|||
}"
|
||||
:item="getProjectMenuItem(project)"
|
||||
:compact="props.collapsed"
|
||||
:handle-select="projectClicked"
|
||||
:active-tab="projectsStore.projectNavActiveId"
|
||||
mode="tabs"
|
||||
data-test-id="project-menu-item"
|
||||
/>
|
||||
</ElMenu>
|
||||
<N8nTooltip placement="right" :disabled="projectsStore.canCreateProjects">
|
||||
<ElMenu
|
||||
<N8nTooltip
|
||||
v-if="
|
||||
projectsStore.hasPermissionToCreateProjects && projectsStore.isTeamProjectFeatureEnabled
|
||||
"
|
||||
:collapse="props.collapsed"
|
||||
class="pl-xs pr-xs"
|
||||
placement="right"
|
||||
:disabled="projectsStore.canCreateProjects"
|
||||
>
|
||||
<ElMenu :collapse="props.collapsed" class="pl-xs pr-xs">
|
||||
<N8nMenuItem
|
||||
:item="addProject"
|
||||
:compact="props.collapsed"
|
||||
|
@ -203,6 +222,15 @@ onMounted(async () => {
|
|||
.collapsed {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.projectsLabel {
|
||||
margin: 0 var(--spacing-xs) var(--spacing-s);
|
||||
padding: 0 var(--spacing-s);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -2479,6 +2479,8 @@
|
|||
"settings.mfa.updateConfiguration": "MFA configuration updated",
|
||||
"settings.mfa.invalidAuthenticatorCode": "Invalid authenticator code",
|
||||
"projects.menu.home": "Home",
|
||||
"projects.menu.title": "Projects",
|
||||
"projects.menu.personal": "Personal",
|
||||
"projects.menu.addProject": "Add project",
|
||||
"projects.settings": "Project settings",
|
||||
"projects.settings.newProjectName": "My project",
|
||||
|
|
|
@ -91,6 +91,7 @@ import {
|
|||
faInfoCircle,
|
||||
faKey,
|
||||
faLanguage,
|
||||
faLayerGroup,
|
||||
faLink,
|
||||
faList,
|
||||
faLightbulb,
|
||||
|
@ -260,6 +261,7 @@ export const FontAwesomePlugin: Plugin = {
|
|||
addIcon(faInfoCircle);
|
||||
addIcon(faKey);
|
||||
addIcon(faLanguage);
|
||||
addIcon(faLayerGroup);
|
||||
addIcon(faLink);
|
||||
addIcon(faList);
|
||||
addIcon(faLightbulb);
|
||||
|
|
|
@ -60,12 +60,8 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
);
|
||||
const teamProjects = computed(() => projects.value.filter((p) => p.type === ProjectTypes.Team));
|
||||
const teamProjectsLimit = computed(() => settingsStore.settings.enterprise.projects.team.limit);
|
||||
const isTeamProjectFeatureEnabled = computed<boolean>(
|
||||
() => settingsStore.settings.enterprise.projects.team.limit !== 0,
|
||||
);
|
||||
const hasUnlimitedProjects = computed<boolean>(
|
||||
() => settingsStore.settings.enterprise.projects.team.limit === -1,
|
||||
);
|
||||
const isTeamProjectFeatureEnabled = computed<boolean>(() => teamProjectsLimit.value !== 0);
|
||||
const hasUnlimitedProjects = computed<boolean>(() => teamProjectsLimit.value === -1);
|
||||
const isTeamProjectLimitExceeded = computed<boolean>(
|
||||
() => projectsCount.value.team >= teamProjectsLimit.value,
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue