mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-13 16:14:07 -08:00
refactor(editor): Move executions under projects (#11541)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
This commit is contained in:
parent
e3871dbfe2
commit
3e0c6cb3d2
|
@ -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');
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -1305,6 +1305,7 @@ export type ExecutionFilterType = {
|
|||
|
||||
export type ExecutionsQueryFilter = {
|
||||
status?: ExecutionStatus[];
|
||||
projectId?: string;
|
||||
workflowId?: string;
|
||||
finished?: boolean;
|
||||
waitTill?: boolean;
|
||||
|
|
|
@ -31,7 +31,7 @@ export function createTestProject(data: Partial<Project>): 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,
|
||||
|
|
|
@ -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',
|
||||
|
|
120
packages/editor-ui/src/components/Projects/ProjectHeader.test.ts
Normal file
120
packages/editor-ui/src/components/Projects/ProjectHeader.test.ts
Normal file
|
@ -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<typeof useRoute>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
84
packages/editor-ui/src/components/Projects/ProjectHeader.vue
Normal file
84
packages/editor-ui/src/components/Projects/ProjectHeader.vue
Normal file
|
@ -0,0 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
const route = useRoute();
|
||||
const i18n = useI18n();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
const projectPermissions = computed(
|
||||
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
|
||||
);
|
||||
|
||||
const showSettings = computed(
|
||||
() =>
|
||||
!!route?.params?.projectId &&
|
||||
!!projectPermissions.value.update &&
|
||||
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div :class="[$style.projectHeader]">
|
||||
<div :class="[$style.icon]">
|
||||
<N8nIcon :icon="headerIcon" color="text-light"></N8nIcon>
|
||||
</div>
|
||||
<div>
|
||||
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</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>
|
||||
<ProjectTabs :show-settings="showSettings" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.projectHeader {
|
||||
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,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<typeof useRoute>;
|
||||
let projectsStore: ReturnType<typeof useProjectsStore>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<RouteRecordName | null | undefined>('');
|
||||
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,
|
||||
|
|
|
@ -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) {
|
|||
|
||||
<template>
|
||||
<div :class="$style.execListWrapper">
|
||||
<ProjectHeader />
|
||||
<div :class="$style.execList">
|
||||
<div :class="$style.execListHeader">
|
||||
<N8nHeading tag="h1" size="2xlarge">
|
||||
{{ i18n.baseText('executionsList.workflowExecutions') }}
|
||||
</N8nHeading>
|
||||
<div :class="$style.execListHeaderControls">
|
||||
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
|
||||
<ElCheckbox
|
||||
|
@ -334,6 +333,7 @@ async function onAutoRefreshToggle(value: boolean) {
|
|||
<ExecutionsFilter
|
||||
v-show="isMounted"
|
||||
:workflows="workflows"
|
||||
class="execFilter"
|
||||
@filter-changed="onFilterChanged"
|
||||
/>
|
||||
</div>
|
||||
|
@ -455,27 +455,24 @@ async function onAutoRefreshToggle(value: boolean) {
|
|||
<style module lang="scss">
|
||||
.execListWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 0;
|
||||
grid-template-rows: auto auto 1fr 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
||||
}
|
||||
|
||||
.execList {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-l) var(--spacing-l) 0;
|
||||
@media (min-width: 1200px) {
|
||||
padding: var(--spacing-2xl) var(--spacing-2xl) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.execListHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
|
@ -588,3 +585,15 @@ async function onAutoRefreshToggle(value: boolean) {
|
|||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.execFilter:deep(button) {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -313,4 +313,9 @@ function scrollToActiveCard(): void {
|
|||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
|
@ -1,43 +0,0 @@
|
|||
<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,12 +1,8 @@
|
|||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { within, waitFor } from '@testing-library/vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||
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) => {
|
||||
const { RouterLink } = await importOriginal<typeof router>();
|
||||
|
@ -36,46 +32,4 @@ describe('ResourcesListLayout', () => {
|
|||
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, onMounted, watch } from 'vue';
|
||||
|
||||
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
|
||||
import { type ProjectSharingData } from '@/types/projects.types';
|
||||
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
|
||||
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
|
||||
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
|
||||
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import type { DatatableColumn } from 'n8n-design-system';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
@ -99,7 +97,6 @@ const route = useRoute();
|
|||
const i18n = useI18n();
|
||||
const { callDebounced } = useDebounce();
|
||||
const usersStore = useUsersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const sortBy = ref(props.sortOptions[0]);
|
||||
|
@ -330,36 +327,11 @@ onMounted(async () => {
|
|||
hasFilters.value = 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 i18n.baseText('projects.menu.home');
|
||||
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
||||
return i18n.baseText('projects.menu.personal');
|
||||
} else {
|
||||
return projectsStore.currentProject.name;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout>
|
||||
<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">
|
||||
|
|
|
@ -71,6 +71,7 @@ vi.mock('vue-router', async (importOriginal) => {
|
|||
useRouter: vi.fn().mockReturnValue({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
useRoute: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -497,6 +497,7 @@ export const enum VIEWS {
|
|||
PROJECTS_WORKFLOWS = 'ProjectsWorkflows',
|
||||
PROJECTS_CREDENTIALS = 'ProjectsCredentials',
|
||||
PROJECT_SETTINGS = 'ProjectSettings',
|
||||
PROJECTS_EXECUTIONS = 'ProjectsExecutions',
|
||||
}
|
||||
|
||||
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||
|
|
|
@ -893,7 +893,7 @@
|
|||
"mainSidebar.workflows": "Workflows",
|
||||
"mainSidebar.workflows.readOnlyEnv.tooltip": "Protected instances prevent editing workflows (recommended for production environments). {link}",
|
||||
"mainSidebar.workflows.readOnlyEnv.tooltip.link": "More info",
|
||||
"mainSidebar.executions": "All executions",
|
||||
"mainSidebar.executions": "Executions",
|
||||
"mainSidebar.workersView": "Workers",
|
||||
"menuActions.duplicate": "Duplicate",
|
||||
"menuActions.download": "Download",
|
||||
|
|
|
@ -46,7 +46,6 @@ const TemplatesWorkflowView = async () => await import('@/views/TemplatesWorkflo
|
|||
const SetupWorkflowFromTemplateView = async () =>
|
||||
await import('@/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue');
|
||||
const TemplatesSearchView = async () => await import('@/views/TemplatesSearchView.vue');
|
||||
const ExecutionsView = async () => await import('@/views/ExecutionsView.vue');
|
||||
const VariablesView = async () => await import('@/views/VariablesView.vue');
|
||||
const SettingsUsageAndPlan = async () => await import('./views/SettingsUsageAndPlan.vue');
|
||||
const SettingsSso = async () => await import('./views/SettingsSso.vue');
|
||||
|
@ -193,17 +192,6 @@ export const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
meta: { middleware: ['authenticated'] },
|
||||
},
|
||||
{
|
||||
path: '/executions',
|
||||
name: VIEWS.EXECUTIONS,
|
||||
components: {
|
||||
default: ExecutionsView,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/workflow/:name/debug/:executionId',
|
||||
name: VIEWS.EXECUTION_DEBUG,
|
||||
|
|
|
@ -5,6 +5,7 @@ const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
|||
const WorkflowsView = async () => await import('@/views/WorkflowsView.vue');
|
||||
const CredentialsView = async () => await import('@/views/CredentialsView.vue');
|
||||
const ProjectSettings = async () => await import('@/views/ProjectSettings.vue');
|
||||
const ExecutionsView = async () => await import('@/views/ExecutionsView.vue');
|
||||
|
||||
const commonChildRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
|
@ -28,6 +29,16 @@ const commonChildRoutes: RouteRecordRaw[] = [
|
|||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'executions',
|
||||
components: {
|
||||
default: ExecutionsView,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const commonChildRouteExtensions = {
|
||||
|
@ -38,6 +49,9 @@ const commonChildRouteExtensions = {
|
|||
{
|
||||
name: VIEWS.CREDENTIALS,
|
||||
},
|
||||
{
|
||||
name: VIEWS.EXECUTIONS,
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
|
@ -46,6 +60,9 @@ const commonChildRouteExtensions = {
|
|||
{
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
},
|
||||
{
|
||||
name: VIEWS.PROJECTS_EXECUTIONS,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -105,4 +122,8 @@ export const projectsRoutes: RouteRecordRaw[] = [
|
|||
path: '/credentials',
|
||||
redirect: '/home/credentials',
|
||||
},
|
||||
{
|
||||
path: '/executions',
|
||||
redirect: '/home/executions',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -14,9 +14,11 @@ import type {
|
|||
import { useRootStore } from '@/stores/root.store';
|
||||
import { makeRestApiRequest, unflattenExecutionData } from '@/utils/apiUtils';
|
||||
import { executionFilterToQueryFilter, getDefaultExecutionFilters } from '@/utils/executionUtils';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
||||
export const useExecutionsStore = defineStore('executions', () => {
|
||||
const rootStore = useRootStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const itemsPerPage = ref(10);
|
||||
|
@ -24,9 +26,15 @@ export const useExecutionsStore = defineStore('executions', () => {
|
|||
const activeExecution = ref<ExecutionSummary | null>(null);
|
||||
|
||||
const filters = ref<ExecutionFilterType>(getDefaultExecutionFilters());
|
||||
const executionsFilters = computed<ExecutionsQueryFilter>(() =>
|
||||
executionFilterToQueryFilter(filters.value),
|
||||
);
|
||||
const executionsFilters = computed<ExecutionsQueryFilter>(() => {
|
||||
const filter = executionFilterToQueryFilter(filters.value);
|
||||
|
||||
if (projectsStore.currentProjectId) {
|
||||
filter.projectId = projectsStore.currentProjectId;
|
||||
}
|
||||
|
||||
return filter;
|
||||
});
|
||||
const currentExecutionsFilters = computed<Partial<ExecutionFilterType>>(() => ({
|
||||
...(filters.value.workflowId !== 'all' ? { workflowId: filters.value.workflowId } : {}),
|
||||
}));
|
||||
|
|
|
@ -21,12 +21,12 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
|||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
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<{
|
||||
credentialId?: string;
|
||||
|
@ -190,7 +190,7 @@ onMounted(() => {
|
|||
@update:filters="filters = $event"
|
||||
>
|
||||
<template #header>
|
||||
<ProjectTabs />
|
||||
<ProjectHeader />
|
||||
</template>
|
||||
<template #add-button="{ disabled }">
|
||||
<div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import GlobalExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
@ -11,13 +12,13 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
|||
import { storeToRefs } from 'pinia';
|
||||
import type { ExecutionFilterType } from '@/Interface';
|
||||
|
||||
const route = useRoute();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const externalHooks = useExternalHooks();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const { executionsCount, executionsCountEstimated, filters, allExecutions } =
|
||||
|
@ -46,7 +47,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
async function loadWorkflows() {
|
||||
try {
|
||||
await workflowsStore.fetchAllWorkflows();
|
||||
await workflowsStore.fetchAllWorkflows(route.params?.projectId as string | undefined);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.loadWorkflows.title'));
|
||||
}
|
||||
|
|
|
@ -7,8 +7,7 @@ import { useUsersStore } from '@/stores/users.store';
|
|||
import type { IUser } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { type Project, type ProjectRelation, ProjectTypes } from '@/types/projects.types';
|
||||
import { type Project, type ProjectRelation } from '@/types/projects.types';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { VIEWS } from '@/constants';
|
||||
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
|
||||
|
@ -18,7 +17,7 @@ import type { ProjectRole } from '@/types/roles.types';
|
|||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
|
||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||
|
||||
type FormDataDiff = {
|
||||
name?: string;
|
||||
|
@ -248,26 +247,6 @@ watch(
|
|||
{ 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 () => {
|
||||
await usersStore.fetchUsers();
|
||||
});
|
||||
|
@ -281,12 +260,7 @@ onMounted(() => {
|
|||
<template>
|
||||
<div :class="$style.projectSettings">
|
||||
<div :class="$style.header">
|
||||
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
|
||||
<template #title>
|
||||
{{ projectName }}
|
||||
</template>
|
||||
</ResourceListHeader>
|
||||
<ProjectTabs />
|
||||
<ProjectHeader />
|
||||
</div>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<fieldset>
|
||||
|
|
|
@ -15,7 +15,6 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
|
@ -35,6 +34,7 @@ import {
|
|||
N8nTooltip,
|
||||
} from 'n8n-design-system';
|
||||
import { pickBy } from 'lodash-es';
|
||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const route = useRoute();
|
||||
|
@ -311,7 +311,7 @@ onMounted(async () => {
|
|||
@update:filters="onFiltersUpdated"
|
||||
>
|
||||
<template #header>
|
||||
<ProjectTabs />
|
||||
<ProjectHeader />
|
||||
</template>
|
||||
<template #add-button="{ disabled }">
|
||||
<N8nTooltip :disabled="!readOnlyEnv">
|
||||
|
|
Loading…
Reference in a new issue