diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index da9c6fcc65..52a28cba62 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -11,6 +11,7 @@ export const getAddProjectButton = () => 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"]'); +export const getProjectTabExecutions = () => getProjectTabs().filter('a[href$="/executions"]'); export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]'); export const getProjectSettingsNameInput = () => cy.getByTestId('project-settings-name-input').find('input'); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 84d062fff5..0d4154e646 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -51,7 +51,7 @@ describe('Projects', { disableAutoLogin: true }, () => { }); projects.getHomeButton().click(); - projects.getProjectTabs().should('have.length', 2); + projects.getProjectTabs().should('have.length', 3); projects.getProjectTabCredentials().click(); credentialsPage.getters.credentialCards().should('not.have.length'); @@ -101,7 +101,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects.getMenuItems().first().click(); workflowsPage.getters.workflowCards().should('not.have.length'); - projects.getProjectTabs().should('have.length', 3); + projects.getProjectTabs().should('have.length', 4); workflowsPage.getters.newWorkflowButtonCard().click(); @@ -441,9 +441,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('contain.text', 'Notion account personal project'); }); - // Skip flaky test - // eslint-disable-next-line n8n-local-rules/no-skipped-tests - it.skip('should move resources between projects', () => { + it('should move resources between projects', () => { cy.signinAsOwner(); cy.visit(workflowsPage.url); @@ -686,9 +684,7 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('have.length', 1); }); - // Skip flaky test - // eslint-disable-next-line n8n-local-rules/no-skipped-tests - it.skip('should allow to change inaccessible credential when the workflow was moved to a team project', () => { + it('should allow to change inaccessible credential when the workflow was moved to a team project', () => { cy.signinAsOwner(); cy.visit(workflowsPage.url); @@ -701,9 +697,7 @@ describe('Projects', { disableAutoLogin: true }, () => { projects.getHomeButton().click(); workflowsPage.getters.workflowCards().should('not.have.length'); workflowsPage.getters.newWorkflowButtonCard().click(); - workflowsPage.getters.workflowCards().should('not.have.length'); - workflowsPage.getters.newWorkflowButtonCard().click(); workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); ndv.getters.backToCanvas().click(); @@ -789,7 +783,8 @@ describe('Projects', { disableAutoLogin: true }, () => { cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password); cy.getByTestId('form-submit-button').click(); - mainSidebar.getters.executions().click(); + projects.getMenuItems().last().click(); + projects.getProjectTabExecutions().click(); cy.getByTestId('global-execution-list-item').first().find('td:last button').click(); getVisibleDropdown() .find('li') diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 2a140eb76c..9db1ac1040 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1305,6 +1305,7 @@ export type ExecutionFilterType = { export type ExecutionsQueryFilter = { status?: ExecutionStatus[]; + projectId?: string; workflowId?: string; finished?: boolean; waitTill?: boolean; diff --git a/packages/editor-ui/src/__tests__/data/projects.ts b/packages/editor-ui/src/__tests__/data/projects.ts index 1870803cbf..98878c339b 100644 --- a/packages/editor-ui/src/__tests__/data/projects.ts +++ b/packages/editor-ui/src/__tests__/data/projects.ts @@ -31,7 +31,7 @@ export function createTestProject(data: Partial): Project { name: faker.lorem.words({ min: 1, max: 3 }), createdAt: faker.date.past().toISOString(), updatedAt: faker.date.recent().toISOString(), - type: 'team', + type: ProjectTypes.Team, relations: [], scopes: [], ...data, diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index d075a841ba..eefde16b1d 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -98,13 +98,6 @@ const mainMenuItems = computed(() => [ position: 'bottom', route: { to: { name: VIEWS.VARIABLES } }, }, - { - id: 'executions', - icon: 'tasks', - label: locale.baseText('mainSidebar.executions'), - position: 'bottom', - route: { to: { name: VIEWS.EXECUTIONS } }, - }, { id: 'help', icon: 'question', diff --git a/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts new file mode 100644 index 0000000000..46b18718bb --- /dev/null +++ b/packages/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -0,0 +1,120 @@ +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; +import { createTestProject } from '@/__tests__/data/projects'; +import { useRoute } from 'vue-router'; +import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; +import { useProjectsStore } from '@/stores/projects.store'; +import type { Project } from '@/types/projects.types'; +import { ProjectTypes } from '@/types/projects.types'; + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router'); + const params = {}; + const location = {}; + return { + ...actual, + useRoute: () => ({ + params, + location, + }), + }; +}); + +const projectTabsSpy = vi.fn().mockReturnValue({ + render: vi.fn(), +}); + +const renderComponent = createComponentRenderer(ProjectHeader, { + global: { + stubs: { + ProjectTabs: projectTabsSpy, + }, + }, +}); + +let route: ReturnType; +let projectsStore: ReturnType>; + +describe('ProjectHeader', () => { + beforeEach(() => { + createTestingPinia(); + route = useRoute(); + projectsStore = mockedStore(useProjectsStore); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render the correct icon', async () => { + const { container, rerender } = renderComponent(); + + expect(container.querySelector('.fa-home')).toBeVisible(); + + projectsStore.currentProject = { type: ProjectTypes.Personal } as Project; + await rerender({}); + expect(container.querySelector('.fa-user')).toBeVisible(); + + const projectName = 'My Project'; + projectsStore.currentProject = { name: projectName } as Project; + await rerender({}); + expect(container.querySelector('.fa-layer-group')).toBeVisible(); + }); + + it('should render the correct title', async () => { + const { getByText, rerender } = renderComponent(); + + expect(getByText('Home')).toBeVisible(); + + projectsStore.currentProject = { type: ProjectTypes.Personal } as Project; + await rerender({}); + expect(getByText('Personal')).toBeVisible(); + + const projectName = 'My Project'; + projectsStore.currentProject = { name: projectName } as Project; + await rerender({}); + expect(getByText(projectName)).toBeVisible(); + }); + + it('should render ProjectTabs Settings if project is team project and user has update scope', () => { + route.params.projectId = '123'; + projectsStore.currentProject = createTestProject({ scopes: ['project:update'] }); + renderComponent(); + + expect(projectTabsSpy).toHaveBeenCalledWith( + { + 'show-settings': true, + }, + null, + ); + }); + + it('should render ProjectTabs without Settings if no project update permission', () => { + route.params.projectId = '123'; + projectsStore.currentProject = createTestProject({ scopes: ['project:read'] }); + renderComponent(); + + expect(projectTabsSpy).toHaveBeenCalledWith( + { + 'show-settings': false, + }, + null, + ); + }); + + it('should render ProjectTabs without Settings if project is not team project', () => { + route.params.projectId = '123'; + projectsStore.currentProject = createTestProject( + createTestProject({ type: ProjectTypes.Personal, scopes: ['project:update'] }), + ); + renderComponent(); + + expect(projectTabsSpy).toHaveBeenCalledWith( + { + 'show-settings': false, + }, + null, + ); + }); +}); diff --git a/packages/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/editor-ui/src/components/Projects/ProjectHeader.vue new file mode 100644 index 0000000000..a89a195975 --- /dev/null +++ b/packages/editor-ui/src/components/Projects/ProjectHeader.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts b/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts index 45e4a74e1d..c77d7b42cf 100644 --- a/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectTabs.test.ts @@ -1,22 +1,14 @@ -import { createPinia, setActivePinia } from 'pinia'; -import { useRoute } from 'vue-router'; import { createComponentRenderer } from '@/__tests__/render'; -import { createTestProject } from '@/__tests__/data/projects'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; -import { useProjectsStore } from '@/stores/projects.store'; -import { ProjectTypes } from '@/types/projects.types'; -vi.mock('vue-router', () => { +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router'); const params = {}; - const push = vi.fn(); return { + ...actual, useRoute: () => ({ params, }), - useRouter: () => ({ - push, - }), - RouterLink: vi.fn(), }; }); const renderComponent = createComponentRenderer(ProjectTabs, { @@ -29,54 +21,22 @@ const renderComponent = createComponentRenderer(ProjectTabs, { }, }); -let route: ReturnType; -let projectsStore: ReturnType; - describe('ProjectTabs', () => { - beforeEach(() => { - const pinia = createPinia(); - setActivePinia(pinia); - route = useRoute(); - projectsStore = useProjectsStore(); - }); - it('should render home tabs', async () => { const { getByText, queryByText } = renderComponent(); expect(getByText('Workflows')).toBeInTheDocument(); expect(getByText('Credentials')).toBeInTheDocument(); + expect(getByText('Executions')).toBeInTheDocument(); expect(queryByText('Project settings')).not.toBeInTheDocument(); }); - it('should render project tab Settings if user has permissions and current project is of type Team', () => { - route.params.projectId = '123'; - projectsStore.setCurrentProject(createTestProject({ scopes: ['project:update'] })); - const { getByText } = renderComponent(); + it('should render project tab Settings', () => { + const { getByText } = renderComponent({ props: { showSettings: true } }); expect(getByText('Workflows')).toBeInTheDocument(); expect(getByText('Credentials')).toBeInTheDocument(); + expect(getByText('Executions')).toBeInTheDocument(); expect(getByText('Project settings')).toBeInTheDocument(); }); - - it('should render project tabs without Settings if no permission', () => { - route.params.projectId = '123'; - projectsStore.setCurrentProject(createTestProject({ scopes: ['project:read'] })); - const { queryByText, getByText } = renderComponent(); - - expect(getByText('Workflows')).toBeInTheDocument(); - expect(getByText('Credentials')).toBeInTheDocument(); - expect(queryByText('Project settings')).not.toBeInTheDocument(); - }); - - it('should render project tabs without Settings if project is the Personal project', () => { - route.params.projectId = '123'; - projectsStore.setCurrentProject( - createTestProject({ type: ProjectTypes.Personal, scopes: ['project:update'] }), - ); - const { queryByText, getByText } = renderComponent(); - - expect(getByText('Workflows')).toBeInTheDocument(); - expect(getByText('Credentials')).toBeInTheDocument(); - expect(queryByText('Project settings')).not.toBeInTheDocument(); - }); }); diff --git a/packages/editor-ui/src/components/Projects/ProjectTabs.vue b/packages/editor-ui/src/components/Projects/ProjectTabs.vue index 3426f9de5f..19b3269056 100644 --- a/packages/editor-ui/src/components/Projects/ProjectTabs.vue +++ b/packages/editor-ui/src/components/Projects/ProjectTabs.vue @@ -4,19 +4,15 @@ import type { RouteRecordName } from 'vue-router'; import { useRoute } from 'vue-router'; import { VIEWS } from '@/constants'; import { useI18n } from '@/composables/useI18n'; -import { useProjectsStore } from '@/stores/projects.store'; -import { getResourcePermissions } from '@/permissions'; -import { ProjectTypes } from '@/types/projects.types'; + +const props = defineProps<{ + showSettings?: boolean; +}>(); const locale = useI18n(); const route = useRoute(); -const projectsStore = useProjectsStore(); - const selectedTab = ref(''); -const projectPermissions = computed( - () => getResourcePermissions(projectsStore.currentProject?.scopes).project, -); const options = computed(() => { const projectId = route?.params?.projectId; const to = projectId @@ -29,6 +25,10 @@ const options = computed(() => { name: VIEWS.PROJECTS_CREDENTIALS, params: { projectId }, }, + executions: { + name: VIEWS.PROJECTS_EXECUTIONS, + params: { projectId }, + }, } : { workflows: { @@ -37,6 +37,9 @@ const options = computed(() => { credentials: { name: VIEWS.CREDENTIALS, }, + executions: { + name: VIEWS.EXECUTIONS, + }, }; const tabs = [ { @@ -49,13 +52,14 @@ const options = computed(() => { value: to.credentials.name, to: to.credentials, }, + { + label: locale.baseText('mainSidebar.executions'), + value: to.executions.name, + to: to.executions, + }, ]; - if ( - projectId && - projectPermissions.value.update && - projectsStore.currentProject?.type === ProjectTypes.Team - ) { + if (props.showSettings) { tabs.push({ label: locale.baseText('projects.settings'), value: VIEWS.PROJECT_SETTINGS, diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue index e11821fd1d..0a4dbecba3 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue @@ -14,6 +14,7 @@ import { useExecutionsStore } from '@/stores/executions.store'; import type { PermissionsRecord } from '@/permissions'; import { getResourcePermissions } from '@/permissions'; import { useSettingsStore } from '@/stores/settings.store'; +import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; const props = withDefaults( defineProps<{ @@ -315,11 +316,9 @@ async function onAutoRefreshToggle(value: boolean) {