diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index 52a28cba62..3e1b2fd46a 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -6,8 +6,32 @@ const credentialsModal = new CredentialsModal(); export const getHomeButton = () => cy.getByTestId('project-home-menu-item'); export const getMenuItems = () => cy.getByTestId('project-menu-item'); -export const getAddProjectButton = () => - cy.getByTestId('add-project-menu-item').should('contain', 'Add project').should('be.visible'); +export const getAddProjectButton = () => { + cy.getByTestId('universal-add').should('be.visible').click(); + cy.getByTestId('universal-add') + .find('.el-sub-menu__title') + .as('menuitem') + .should('have.attr', 'aria-describedby'); + + cy.get('@menuitem') + .invoke('attr', 'aria-describedby') + .then((el) => cy.get(`[id="${el}"]`)) + .as('submenu'); + + cy.get('@submenu').within((submenu) => + cy + .wrap(submenu) + .getByTestId('navigation-menu-item') + .should('be.visible') + .filter(':contains("Project")') + .as('button'), + ); + + return cy.get('@button'); +}; + +// export const getAddProjectButton = () => +// cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible'); export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); diff --git a/cypress/pages/credentials.ts b/cypress/pages/credentials.ts index 9b20b48ec4..d5fa9cc0b1 100644 --- a/cypress/pages/credentials.ts +++ b/cypress/pages/credentials.ts @@ -5,7 +5,49 @@ export class CredentialsPage extends BasePage { getters = { emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), - createCredentialButton: () => cy.getByTestId('resources-list-add'), + createCredentialButton: () => { + cy.getByTestId('resource-add').should('be.visible').click(); + cy.getByTestId('resource-add') + .find('.el-sub-menu__title') + .as('menuitem') + .should('have.attr', 'aria-describedby'); + + cy.get('@menuitem') + .should('be.visible') + .invoke('attr', 'aria-describedby') + .then((el) => cy.get(`[id="${el}"]`)) + .as('submenu'); + + cy.get('@submenu') + .should('be.visible') + .within((submenu) => { + // If submenu has another submenu + if (submenu.find('[data-test-id="navigation-submenu"]').length) { + cy.wrap(submenu) + .find('[data-test-id="navigation-submenu"]') + .should('be.visible') + .filter(':contains("Credential")') + .as('child') + .click(); + + cy.get('@child') + .should('be.visible') + .find('[data-test-id="navigation-submenu-item"]') + .should('be.visible') + .filter(':contains("Personal")') + .as('button'); + } else { + cy.wrap(submenu) + .find('[data-test-id="navigation-menu-item"]') + .filter(':contains("Credential")') + .as('button'); + } + }); + + return cy.get('@button').should('be.visible'); + }, + + // cy.getByTestId('resources-list-add'), searchInput: () => cy.getByTestId('resources-list-search'), emptyList: () => cy.getByTestId('resources-list-empty'), credentialCards: () => cy.getByTestId('resources-list-item'), diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 5829ecb863..199cc3d31c 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -7,7 +7,47 @@ export class WorkflowsPage extends BasePage { newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'), newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'), searchBar: () => cy.getByTestId('resources-list-search'), - createWorkflowButton: () => cy.getByTestId('resources-list-add'), + createWorkflowButton: () => { + cy.getByTestId('resource-add').should('be.visible').click(); + cy.getByTestId('resource-add') + .find('.el-sub-menu__title') + .as('menuitem') + .should('have.attr', 'aria-describedby'); + + cy.get('@menuitem') + .should('be.visible') + .invoke('attr', 'aria-describedby') + .then((el) => cy.get(`[id="${el}"]`)) + .as('submenu'); + + cy.get('@submenu') + .should('be.visible') + .within((submenu) => { + // If submenu has another submenu + if (submenu.find('[data-test-id="navigation-submenu"]').length) { + cy.wrap(submenu) + .find('[data-test-id="navigation-submenu"]') + .should('be.visible') + .filter(':contains("Workflow")') + .as('child') + .click(); + + cy.get('@child') + .should('be.visible') + .find('[data-test-id="navigation-submenu-item"]') + .should('be.visible') + .filter(':contains("Personal")') + .as('button'); + } else { + cy.wrap(submenu) + .find('[data-test-id="navigation-menu-item"]') + .filter(':contains("Workflow")') + .as('button'); + } + }); + + return cy.get('@button').should('be.visible'); + }, workflowCards: () => cy.getByTestId('resources-list-item'), workflowCard: (workflowName: string) => this.getters diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index 7e7dc3a774..aa677b037d 100644 --- a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -537,13 +537,11 @@ code[class^='language-'] { } .inputWrapper { - position: absolute; display: flex; - bottom: 0; background-color: var(--color-foreground-xlight); border: var(--border-base); width: 100%; - padding-top: 1px; + border-top: 0; textarea { border: none; diff --git a/packages/design-system/src/components/N8nMenu/Menu.vue b/packages/design-system/src/components/N8nMenu/Menu.vue index 596206977f..04d25f0864 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/design-system/src/components/N8nMenu/Menu.vue @@ -132,6 +132,7 @@ const onSelect = (item: IMenuItem): void => { display: flex; flex-direction: column; background-color: var(--menu-background, var(--color-background-xlight)); + overflow: hidden; } .menuHeader { diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts index adb73aa6d1..a7d9936e08 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts @@ -29,9 +29,10 @@ describe('N8nNavigationDropdown', () => { await router.isReady(); }); + it('default slot should trigger first level', async () => { const { getByTestId, queryByTestId } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] }, global: { plugins: [router], @@ -40,13 +41,13 @@ describe('N8nNavigationDropdown', () => { expect(getByTestId('test-trigger')).toBeVisible(); expect(queryByTestId('navigation-menu-item')).not.toBeVisible(); - await userEvent.hover(getByTestId('test-trigger')); + await userEvent.click(getByTestId('test-trigger')); await waitFor(() => expect(queryByTestId('navigation-menu-item')).toBeVisible()); }); it('redirect to route', async () => { const { getByTestId, queryByTestId } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [ { @@ -64,7 +65,7 @@ describe('N8nNavigationDropdown', () => { expect(getByTestId('test-trigger')).toBeVisible(); expect(queryByTestId('navigation-submenu')).not.toBeVisible(); - await userEvent.hover(getByTestId('test-trigger')); + await userEvent.click(getByTestId('test-trigger')); await waitFor(() => expect(getByTestId('navigation-submenu')).toBeVisible()); @@ -75,7 +76,7 @@ describe('N8nNavigationDropdown', () => { it('should render icons in submenu when provided', () => { const { getByTestId } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [ { @@ -95,7 +96,7 @@ describe('N8nNavigationDropdown', () => { it('should propagate events', async () => { const { getByTestId, emitted } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [ { diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index 7657d73211..ba905e75cf 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -1,5 +1,6 @@ @@ -49,7 +73,7 @@ const emit = defineEmits<{ {{ item.title }} - + - + :global(.el-sub-menu) { + > :global(.el-sub-menu__title) { + height: auto; + line-height: initial; + border-bottom: 0 !important; + padding: 0; + :global(.el-sub-menu__icon-arrow) { + display: none; + } } - } - :global(.el-sub-menu__title:hover) { - background-color: transparent; + &:global(.is-active) { + :global(.el-sub-menu__title) { + border: 0; + } + } } } .submenu { + padding: 5px 0 !important; + :global(.el-menu--horizontal .el-menu .el-menu-item), :global(.el-menu--horizontal .el-menu .el-sub-menu__title) { color: var(--color-text-dark); @@ -109,6 +138,15 @@ const emit = defineEmits<{ background-color: var(--color-foreground-base); } + :global(.el-popper) { + padding: 0 10px !important; + } + + :global(.el-menu--popup) { + border: 1px solid var(--color-foreground-base); + border-radius: var(--border-radius-base); + } + :global(.el-menu--horizontal .el-menu .el-menu-item.is-disabled) { opacity: 1; cursor: default; diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index dbcbb2a1c4..c48b1574ca 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -190,7 +190,6 @@ watch(defaultLocale, (newLocale) => { .sidebar { grid-area: sidebar; - height: 100%; z-index: var(--z-index-app-sidebar); } diff --git a/packages/editor-ui/src/components/AskAssistant/AskAssistantChat.vue b/packages/editor-ui/src/components/AskAssistant/AskAssistantChat.vue index d83193815b..c93a175a27 100644 --- a/packages/editor-ui/src/components/AskAssistant/AskAssistantChat.vue +++ b/packages/editor-ui/src/components/AskAssistant/AskAssistantChat.vue @@ -75,7 +75,6 @@ function onClose() { v-show="assistantStore.isAssistantOpen" :supported-directions="['left']" :width="assistantStore.chatWidth" - :class="$style.container" data-test-id="ask-assistant-sidebar" @resize="onResizeDebounced" > diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 3ae4dc147e..bb35959474 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -1,7 +1,6 @@ @@ -305,11 +314,19 @@ const checkWidthAndAdjustSidebar = async (width: number) => { + + + + + + - - - { border-right: var(--border-width-base) var(--border-style-base) var(--color-foreground-base); transition: width 150ms ease-in-out; width: $sidebar-expanded-width; + padding-top: 54px; + background-color: var(--menu-background, var(--color-background-xlight)); + .logo { - height: $header-height; + position: absolute; + top: 0; + left: 0; + width: 100%; display: flex; align-items: center; padding: var(--spacing-xs); + justify-content: space-between; img { position: relative; @@ -409,6 +433,12 @@ const checkWidthAndAdjustSidebar = async (width: number) => { &.sideMenuCollapsed { width: $sidebar-width; + padding-top: 90px; + + .logo { + flex-direction: column; + gap: 16px; + } .logo img { left: 0; diff --git a/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts index 01c96a8ad8..e0ec954118 100644 --- a/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -29,6 +29,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, { global: { stubs: { ProjectTabs: projectTabsSpy, + N8nNavigationDropdown: true, }, }, }); @@ -41,6 +42,8 @@ describe('ProjectHeader', () => { createTestingPinia(); route = useRoute(); projectsStore = mockedStore(useProjectsStore); + + projectsStore.teamProjectsLimit = -1; }); afterEach(() => { diff --git a/packages/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/editor-ui/src/components/Projects/ProjectHeader.vue index 6432bfcfcb..1446e7e647 100644 --- a/packages/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/editor-ui/src/components/Projects/ProjectHeader.vue @@ -1,16 +1,21 @@ @@ -55,11 +78,18 @@ const showSettings = computed( - - - - + + + + + + @@ -79,6 +109,9 @@ const showSettings = computed( } .actions { - margin-left: auto; + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: var(--spacing-2xs) 0 var(--spacing-l); } diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts b/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts index 6b20301d66..c75e9a174a 100644 --- a/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.test.ts @@ -1,15 +1,10 @@ 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 { createRouter, createMemoryHistory } from 'vue-router'; import { createProjectListItem } from '@/__tests__/data/projects'; import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue'; import { useProjectsStore } from '@/stores/projects.store'; import { mockedStore } from '@/__tests__/utils'; -import type { Project } from '@/types/projects.types'; -import { VIEWS } from '@/constants'; -import { useToast } from '@/composables/useToast'; -import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router'); @@ -62,10 +57,7 @@ const renderComponent = createComponentRenderer(ProjectsNavigation, { }, }); -let router: ReturnType; -let toast: ReturnType; let projectsStore: ReturnType>; -let pageRedirectionHelper: ReturnType; const personalProjects = Array.from({ length: 3 }, createProjectListItem); const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team')); @@ -74,10 +66,6 @@ describe('ProjectsNavigation', () => { beforeEach(() => { createTestingPinia(); - router = useRouter(); - toast = useToast(); - pageRedirectionHelper = usePageRedirectionHelper(); - projectsStore = mockedStore(useProjectsStore); }); @@ -92,70 +80,6 @@ describe('ProjectsNavigation', () => { }).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(pageRedirectionHelper.goToUpgrade).toHaveBeenCalledWith('rbac', 'upgrade-rbac'); - }); - it('should show "Projects" title and Personal project when the feature is enabled', async () => { projectsStore.isTeamProjectFeatureEnabled = true; projectsStore.myProjects = [...personalProjects, ...teamProjects]; diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue index fd9145bb99..79316f34f4 100644 --- a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue @@ -1,14 +1,11 @@ @@ -153,39 +103,6 @@ onMounted(async () => { data-test-id="project-menu-item" /> - - - - - - - {{ props.planName }} - - {{ - locale.baseText('projects.create.limit', { - adjustToNumber: projectsStore.teamProjectsLimit, - interpolate: { num: String(projectsStore.teamProjectsLimit) }, - }) - }} - - - - {{ locale.baseText('projects.create.limitReached.link') }} - - - - - diff --git a/packages/editor-ui/src/components/Projects/ProjectTabs.vue b/packages/editor-ui/src/components/Projects/ProjectTabs.vue index 19b3269056..f917a360f3 100644 --- a/packages/editor-ui/src/components/Projects/ProjectTabs.vue +++ b/packages/editor-ui/src/components/Projects/ProjectTabs.vue @@ -79,13 +79,5 @@ watch( - - - + - - diff --git a/packages/editor-ui/src/components/layouts/ResourcesListLayout.test.ts b/packages/editor-ui/src/components/layouts/ResourcesListLayout.test.ts index 7c286bdede..327e003bf1 100644 --- a/packages/editor-ui/src/components/layouts/ResourcesListLayout.test.ts +++ b/packages/editor-ui/src/components/layouts/ResourcesListLayout.test.ts @@ -4,6 +4,12 @@ import { createComponentRenderer } from '@/__tests__/render'; import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue'; import type router from 'vue-router'; +vi.mock('@/composables/useGlobalEntityCreation', () => ({ + useGlobalEntityCreation: () => ({ + menu: [], + }), +})); + vi.mock('vue-router', async (importOriginal) => { const { RouterLink } = await importOriginal(); return { diff --git a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue index e225cc8ab1..40f1d1e07c 100644 --- a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -83,7 +83,7 @@ defineSlots<{ empty(): unknown; preamble(): unknown; postamble(): unknown; - 'add-button'(props: { disabled: boolean }): unknown; + 'add-button'(): unknown; callout(): unknown; filters(props: { filters: Record; @@ -407,16 +407,7 @@ onMounted(async () => { - - - {{ i18n.baseText(`${resourceKey}.add` as BaseTextKey) }} - - + diff --git a/packages/editor-ui/src/composables/useGlobalEntityCreation.test.ts b/packages/editor-ui/src/composables/useGlobalEntityCreation.test.ts new file mode 100644 index 0000000000..d6fa2d8837 --- /dev/null +++ b/packages/editor-ui/src/composables/useGlobalEntityCreation.test.ts @@ -0,0 +1,222 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { useProjectsStore } from '@/stores/projects.store'; +import { useSourceControlStore } from '@/stores/sourceControl.store'; +import { mockedStore } from '@/__tests__/utils'; +import type router from 'vue-router'; +import { flushPromises } from '@vue/test-utils'; +import { useToast } from '@/composables/useToast'; +import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; + +import { VIEWS } from '@/constants'; +import type { Project, ProjectListItem } from '@/types/projects.types'; + +import { useGlobalEntityCreation } from './useGlobalEntityCreation'; + +vi.mock('@/composables/usePageRedirectionHelper', () => { + const goToUpgrade = vi.fn(); + return { + usePageRedirectionHelper: () => ({ + goToUpgrade, + }), + }; +}); + +vi.mock('@/composables/useToast', () => { + const showMessage = vi.fn(); + const showError = vi.fn(); + return { + useToast: () => { + return { + showMessage, + showError, + }; + }, + }; +}); + +const routerPushMock = vi.fn(); +vi.mock('vue-router', async (importOriginal) => { + const { RouterLink, useRoute } = await importOriginal(); + return { + RouterLink, + useRoute, + useRouter: () => ({ + push: routerPushMock, + }), + }; +}); + +beforeEach(() => { + setActivePinia(createTestingPinia()); + routerPushMock.mockReset(); +}); + +describe('useGlobalEntityCreation', () => { + it('should not contain projects for community', () => { + const projectsStore = mockedStore(useProjectsStore); + + const personalProjectId = 'personal-project'; + projectsStore.canCreateProjects = false; + projectsStore.personalProject = { id: personalProjectId } as Project; + const { menu } = useGlobalEntityCreation(); + + expect(menu.value[0]).toStrictEqual( + expect.objectContaining({ + route: { name: VIEWS.NEW_WORKFLOW, query: { projectId: personalProjectId } }, + }), + ); + + expect(menu.value[1]).toStrictEqual( + expect.objectContaining({ + route: { + name: VIEWS.CREDENTIALS, + params: { projectId: personalProjectId, credentialId: 'create' }, + }, + }), + ); + }); + + describe('single project', () => { + const currentProjectId = 'current-project'; + + it('should use currentProject', () => { + const projectsStore = mockedStore(useProjectsStore); + + projectsStore.canCreateProjects = true; + projectsStore.currentProject = { id: currentProjectId } as Project; + + const { menu } = useGlobalEntityCreation(false); + + expect(menu.value[0]).toStrictEqual( + expect.objectContaining({ + route: { name: VIEWS.NEW_WORKFLOW, query: { projectId: currentProjectId } }, + }), + ); + + expect(menu.value[1]).toStrictEqual( + expect.objectContaining({ + route: { + name: VIEWS.PROJECTS_CREDENTIALS, + params: { projectId: currentProjectId, credentialId: 'create' }, + }, + }), + ); + }); + + it('should be disabled in readOnly', () => { + const projectsStore = mockedStore(useProjectsStore); + + projectsStore.canCreateProjects = true; + projectsStore.currentProject = { id: currentProjectId } as Project; + + const sourceControl = mockedStore(useSourceControlStore); + sourceControl.preferences.branchReadOnly = true; + + const { menu } = useGlobalEntityCreation(false); + + expect(menu.value[0]).toStrictEqual( + expect.objectContaining({ + disabled: true, + }), + ); + + expect(menu.value[1]).toStrictEqual( + expect.objectContaining({ + disabled: true, + }), + ); + }); + + it('should be disabled based in scopes', () => { + const projectsStore = mockedStore(useProjectsStore); + + projectsStore.canCreateProjects = true; + projectsStore.currentProject = { id: currentProjectId, scopes: [] } as unknown as Project; + + const { menu } = useGlobalEntityCreation(false); + + expect(menu.value[0]).toStrictEqual( + expect.objectContaining({ + disabled: true, + }), + ); + + expect(menu.value[1]).toStrictEqual( + expect.objectContaining({ + disabled: true, + }), + ); + }); + }); + + describe('global', () => { + it('should show personal + all team projects', () => { + const projectsStore = mockedStore(useProjectsStore); + + const personalProjectId = 'personal-project'; + projectsStore.canCreateProjects = true; + projectsStore.personalProject = { id: personalProjectId } as Project; + projectsStore.myProjects = [ + { id: '1', name: '1', type: 'team' }, + { id: '2', name: '2', type: 'public' }, + { id: '3', name: '3', type: 'team' }, + ] as ProjectListItem[]; + + const { menu } = useGlobalEntityCreation(true); + + expect(menu.value[0].submenu?.length).toBe(4); + expect(menu.value[1].submenu?.length).toBe(4); + }); + }); + + describe('handleSelect()', () => { + it('should only handle create-project', () => { + const projectsStore = mockedStore(useProjectsStore); + projectsStore.canCreateProjects = true; + const { handleSelect } = useGlobalEntityCreation(true); + handleSelect('dummy'); + expect(projectsStore.createProject).not.toHaveBeenCalled(); + }); + + it('creates a new project', async () => { + const toast = useToast(); + const projectsStore = mockedStore(useProjectsStore); + projectsStore.canCreateProjects = true; + projectsStore.createProject.mockResolvedValueOnce({ name: 'test', id: '1' } as Project); + + const { handleSelect } = useGlobalEntityCreation(true); + + handleSelect('create-project'); + await flushPromises(); + + expect(projectsStore.createProject).toHaveBeenCalled(); + expect(routerPushMock).toHaveBeenCalled(); + expect(toast.showMessage).toHaveBeenCalled(); + }); + + it('handles create project error', async () => { + const toast = useToast(); + const projectsStore = mockedStore(useProjectsStore); + projectsStore.canCreateProjects = true; + projectsStore.createProject.mockRejectedValueOnce(new Error('error')); + + const { handleSelect } = useGlobalEntityCreation(true); + + handleSelect('create-project'); + await flushPromises(); + expect(toast.showError).toHaveBeenCalled(); + }); + + it('redirects when project limit has been reached', () => { + const projectsStore = mockedStore(useProjectsStore); + projectsStore.canCreateProjects = false; + const redirect = usePageRedirectionHelper(); + + const { handleSelect } = useGlobalEntityCreation(true); + + handleSelect('create-project'); + expect(redirect.goToUpgrade).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/editor-ui/src/composables/useGlobalEntityCreation.ts b/packages/editor-ui/src/composables/useGlobalEntityCreation.ts new file mode 100644 index 0000000000..cd25b4c71f --- /dev/null +++ b/packages/editor-ui/src/composables/useGlobalEntityCreation.ts @@ -0,0 +1,208 @@ +import { computed, toValue, type ComputedRef, type Ref } from 'vue'; +import { VIEWS } from '@/constants'; +import { useRouter } from 'vue-router'; +import { useI18n } from '@/composables/useI18n'; +import { sortByProperty } from '@/utils/sortUtils'; +import { useToast } from '@/composables/useToast'; +import { useProjectsStore } from '@/stores/projects.store'; +import { useSourceControlStore } from '@/stores/sourceControl.store'; +import { getResourcePermissions } from '@/permissions'; +import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; +import type { Scope } from '@n8n/permissions'; +import type { RouteLocationRaw } from 'vue-router'; + +type BaseItem = { + id: string; + title: string; + disabled?: boolean; + icon?: string; + route?: RouteLocationRaw; +}; + +type Item = BaseItem & { + submenu?: BaseItem[]; +}; + +export const useGlobalEntityCreation = ( + multipleProjects: Ref | ComputedRef | boolean = true, +) => { + const CREATE_PROJECT_ID = 'create-project'; + + const projectsStore = useProjectsStore(); + const sourceControlStore = useSourceControlStore(); + const router = useRouter(); + const i18n = useI18n(); + const toast = useToast(); + const displayProjects = computed(() => + sortByProperty( + 'name', + projectsStore.myProjects.filter((p) => p.type === 'team'), + ), + ); + + const disabledWorkflow = (scopes: Scope[] = []): boolean => + sourceControlStore.preferences.branchReadOnly || + !getResourcePermissions(scopes).workflow.create; + + const disabledCredential = (scopes: Scope[] = []): boolean => + sourceControlStore.preferences.branchReadOnly || + !getResourcePermissions(scopes).credential.create; + + const menu = computed(() => { + // Community + if (!projectsStore.canCreateProjects) { + return [ + { + id: 'workflow', + title: 'Workflow', + route: { + name: VIEWS.NEW_WORKFLOW, + query: { + projectId: projectsStore.personalProject?.id, + }, + }, + }, + { + id: 'credential', + title: 'Credential', + route: { + name: VIEWS.CREDENTIALS, + params: { + projectId: projectsStore.personalProject?.id, + credentialId: 'create', + }, + }, + }, + ]; + } + + // single project + if (!toValue(multipleProjects)) { + return [ + { + id: 'workflow', + title: 'Workflow', + disabled: disabledWorkflow(projectsStore.currentProject?.scopes), + route: { + name: VIEWS.NEW_WORKFLOW, + query: { + projectId: projectsStore.currentProject?.id, + }, + }, + }, + { + id: 'credential', + title: 'Credential', + disabled: disabledCredential(projectsStore.currentProject?.scopes), + route: { + name: VIEWS.PROJECTS_CREDENTIALS, + params: { + projectId: projectsStore.currentProject?.id, + credentialId: 'create', + }, + }, + }, + ]; + } + + // global + return [ + { + id: 'workflow', + title: 'Workflow', + submenu: [ + { + id: 'workflow-title', + title: 'Create in', + disabled: true, + }, + { + id: 'workflow-personal', + title: i18n.baseText('projects.menu.personal'), + icon: 'user', + disabled: disabledWorkflow(projectsStore.personalProject?.scopes), + route: { + name: VIEWS.NEW_WORKFLOW, + query: { projectId: projectsStore.personalProject?.id }, + }, + }, + ...displayProjects.value.map((project) => ({ + id: `workflow-${project.id}`, + title: project.name as string, + icon: 'layer-group', + disabled: disabledWorkflow(project.scopes), + route: { + name: VIEWS.NEW_WORKFLOW, + query: { projectId: project.id }, + }, + })), + ], + }, + { + id: 'credential', + title: 'Credential', + submenu: [ + { + id: 'credential-title', + title: 'Create in', + disabled: true, + }, + { + id: 'credential-personal', + title: i18n.baseText('projects.menu.personal'), + icon: 'user', + disabled: disabledCredential(projectsStore.personalProject?.scopes), + route: { + name: VIEWS.PROJECTS_CREDENTIALS, + params: { projectId: projectsStore.personalProject?.id, credentialId: 'create' }, + }, + }, + ...displayProjects.value.map((project) => ({ + id: `credential-${project.id}`, + title: project.name as string, + icon: 'layer-group', + disabled: disabledCredential(project.scopes), + route: { + name: VIEWS.PROJECTS_CREDENTIALS, + params: { projectId: project.id, credentialId: 'create' }, + }, + })), + ], + }, + { + id: CREATE_PROJECT_ID, + title: 'Project', + }, + ]; + }); + + const createProject = async () => { + try { + const newProject = await projectsStore.createProject({ + name: i18n.baseText('projects.settings.newProjectName'), + }); + await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } }); + toast.showMessage({ + title: i18n.baseText('projects.settings.save.successful.title', { + interpolate: { projectName: newProject.name as string }, + }), + type: 'success', + }); + } catch (error) { + toast.showError(error, i18n.baseText('projects.error.title')); + } + }; + + const handleSelect = (id: string) => { + if (id !== CREATE_PROJECT_ID) return; + + if (projectsStore.canCreateProjects) { + void createProject(); + return; + } + + void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac'); + }; + + return { menu, handleSelect }; +}; diff --git a/packages/editor-ui/src/views/CredentialsView.test.ts b/packages/editor-ui/src/views/CredentialsView.test.ts index d45c70c78b..aec4fd2c95 100644 --- a/packages/editor-ui/src/views/CredentialsView.test.ts +++ b/packages/editor-ui/src/views/CredentialsView.test.ts @@ -6,10 +6,14 @@ import { useUIStore } from '@/stores/ui.store'; import { mockedStore } from '@/__tests__/utils'; import { waitFor, within, fireEvent } from '@testing-library/vue'; import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants'; -import userEvent from '@testing-library/user-event'; import { useProjectsStore } from '@/stores/projects.store'; import type { Project } from '@/types/projects.types'; import { useRouter } from 'vue-router'; +vi.mock('@/composables/useGlobalEntityCreation', () => ({ + useGlobalEntityCreation: () => ({ + menu: [], + }), +})); vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router'); @@ -96,28 +100,6 @@ describe('CredentialsView', () => { renderComponent({ props: { credentialId: 'create' } }); expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY); }); - - it('should update credentialId route param to create', async () => { - const projectsStore = mockedStore(useProjectsStore); - projectsStore.isProjectHome = false; - projectsStore.currentProject = { scopes: ['credential:create'] } as Project; - const credentialsStore = mockedStore(useCredentialsStore); - credentialsStore.allCredentials = [ - { - id: '1', - name: 'test', - type: 'test', - createdAt: '2021-05-05T00:00:00Z', - updatedAt: '2021-05-05T00:00:00Z', - }, - ]; - const { getByTestId } = renderComponent(); - - await userEvent.click(getByTestId('resources-list-add')); - await waitFor(() => - expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: 'create' } }), - ); - }); }); describe('open existing credential', () => { diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index 8cf0d6f1aa..710f3a7315 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -25,7 +25,6 @@ import { getResourcePermissions } from '@/permissions'; import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useTelemetry } from '@/composables/useTelemetry'; import { useI18n } from '@/composables/useI18n'; -import { N8nButton, N8nInputLabel, N8nSelect, N8nOption } from 'n8n-design-system'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; const props = defineProps<{ @@ -74,12 +73,6 @@ const credentialTypesById = computed( () => credentialsStore.credentialTypesById, ); -const addCredentialButtonText = computed(() => - projectsStore.currentProject - ? i18n.baseText('credentials.project.add') - : i18n.baseText('credentials.add'), -); - const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); const projectPermissions = computed(() => @@ -192,19 +185,6 @@ onMounted(() => { - - - - {{ addCredentialButtonText }} - - - ({ + useGlobalEntityCreation: () => ({ + menu: [], + }), +})); const router = createRouter({ history: createWebHistory(), @@ -146,55 +151,6 @@ describe('WorkflowsView', () => { }); }); - describe('workflow creation button', () => { - it('should create global workflow', async () => { - const pushSpy = vi.spyOn(router, 'push'); - const pinia = createTestingPinia({ initialState }); - const workflowsStore = mockedStore(useWorkflowsStore); - workflowsStore.allWorkflows = [{ id: '1' } as IWorkflowDb]; - - const projectsStore = mockedStore(useProjectsStore); - projectsStore.fetchProject.mockResolvedValue({} as Project); - projectsStore.personalProject = { scopes: ['workflow:create'] } as Project; - - const { getByTestId } = renderComponent({ pinia }); - expect(getByTestId('resources-list-add')).toBeInTheDocument(); - - expect(getByTestId('resources-list-add').textContent).toBe('Add workflow'); - - await userEvent.click(getByTestId('resources-list-add')); - - expect(pushSpy).toHaveBeenCalledWith({ name: VIEWS.NEW_WORKFLOW, query: { projectId: '' } }); - }); - - it('should create a project specific workflow', async () => { - await router.replace({ path: '/project-id' }); - const pushSpy = vi.spyOn(router, 'push'); - - const pinia = createTestingPinia({ initialState }); - const workflowsStore = mockedStore(useWorkflowsStore); - workflowsStore.allWorkflows = [{ id: '1' } as IWorkflowDb]; - - const projectsStore = mockedStore(useProjectsStore); - - projectsStore.currentProject = { scopes: ['workflow:create'] } as Project; - - const { getByTestId } = renderComponent({ pinia }); - expect(router.currentRoute.value.params.projectId).toBe('project-id'); - - expect(getByTestId('resources-list-add')).toBeInTheDocument(); - - expect(getByTestId('resources-list-add').textContent).toBe('Add workflow to project'); - - await userEvent.click(getByTestId('resources-list-add')); - - expect(pushSpy).toHaveBeenCalledWith({ - name: VIEWS.NEW_WORKFLOW, - query: { projectId: 'project-id' }, - }); - }); - }); - describe('filters', () => { it('should set tag filter based on query parameters', async () => { await router.replace({ query: { tags: 'test-tag' } }); diff --git a/packages/editor-ui/src/views/WorkflowsView.vue b/packages/editor-ui/src/views/WorkflowsView.vue index d1f02274b2..7036de27bc 100644 --- a/packages/editor-ui/src/views/WorkflowsView.vue +++ b/packages/editor-ui/src/views/WorkflowsView.vue @@ -23,7 +23,6 @@ import { useI18n } from '@/composables/useI18n'; import { useRoute, useRouter } from 'vue-router'; import { useTelemetry } from '@/composables/useTelemetry'; import { - N8nButton, N8nCard, N8nHeading, N8nIcon, @@ -31,7 +30,6 @@ import { N8nOption, N8nSelect, N8nText, - N8nTooltip, } from 'n8n-design-system'; import { pickBy } from 'lodash-es'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; @@ -116,12 +114,6 @@ const isSalesUser = computed(() => { return ['Sales', 'sales-and-marketing'].includes(userRole.value || ''); }); -const addWorkflowButtonText = computed(() => { - return projectsStore.currentProject - ? i18n.baseText('workflows.project.add') - : i18n.baseText('workflows.add'); -}); - const projectPermissions = computed(() => { return getResourcePermissions( projectsStore.currentProject?.scopes ?? projectsStore.personalProject?.scopes, @@ -313,30 +305,6 @@ onMounted(async () => { - - - - - {{ addWorkflowButtonText }} - - - - - - - {{ i18n.baseText('mainSidebar.workflows.readOnlyEnv.tooltip.link') }} - - - - - -