diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue index 03fdb7e182..e9a34c7e04 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -104,7 +104,7 @@ const isItemActive = (item: IMenuItem): boolean => { diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts b/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts new file mode 100644 index 0000000000..5ad62ed8e9 --- /dev/null +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts @@ -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: '', + }, + }; +}); + +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: '
Home
' }, + }, + ], + }), + ], + }, +}); + +let router: ReturnType; +let toast: ReturnType; +let projectsStore: ReturnType>; +let uiStore: ReturnType>; + +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(); + }); +}); diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue index 2b4909e0f2..e14328b01e 100644 --- a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue @@ -44,6 +44,7 @@ const addProject = computed(() => ({ 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(() => ({ + 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 () => { { " class="mt-m mb-m" /> + + {{ locale.baseText('projects.menu.title') }} + + { }" :item="getProjectMenuItem(project)" :compact="props.collapsed" - :handle-select="projectClicked" :active-tab="projectsStore.projectNavActiveId" mode="tabs" data-test-id="project-menu-item" /> - - + + { .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); +}