mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 00:24:07 -08:00
Merge branch 'master' of github.com:n8n-io/n8n into ado-2808-1
This commit is contained in:
commit
9e70cb1b05
|
@ -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')
|
||||
|
|
|
@ -35,6 +35,7 @@ describe('OutputParserItemList', () => {
|
|||
|
||||
const { response } = await outputParser.supplyData.call(thisArg, 0);
|
||||
expect(response).toBeInstanceOf(N8nItemListOutputParser);
|
||||
expect((response as any).numberOfItems).toBe(3);
|
||||
});
|
||||
|
||||
it('should create a parser with custom number of items', async () => {
|
||||
|
@ -50,6 +51,20 @@ describe('OutputParserItemList', () => {
|
|||
expect((response as any).numberOfItems).toBe(5);
|
||||
});
|
||||
|
||||
it('should create a parser with unlimited number of items', async () => {
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options') {
|
||||
return { numberOfItems: -1 };
|
||||
}
|
||||
|
||||
throw new ApplicationError('Not implemented');
|
||||
});
|
||||
|
||||
const { response } = await outputParser.supplyData.call(thisArg, 0);
|
||||
expect(response).toBeInstanceOf(N8nItemListOutputParser);
|
||||
expect((response as any).numberOfItems).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create a parser with custom separator', async () => {
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options') {
|
||||
|
|
|
@ -3,16 +3,21 @@ import { BaseOutputParser, OutputParserException } from '@langchain/core/output_
|
|||
export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
||||
lc_namespace = ['n8n-nodes-langchain', 'output_parsers', 'list_items'];
|
||||
|
||||
private numberOfItems: number = 3;
|
||||
private numberOfItems: number | undefined;
|
||||
|
||||
private separator: string;
|
||||
|
||||
constructor(options: { numberOfItems?: number; separator?: string }) {
|
||||
super();
|
||||
if (options.numberOfItems && options.numberOfItems > 0) {
|
||||
this.numberOfItems = options.numberOfItems;
|
||||
|
||||
const { numberOfItems = 3, separator = '\n' } = options;
|
||||
|
||||
if (numberOfItems && numberOfItems > 0) {
|
||||
this.numberOfItems = numberOfItems;
|
||||
}
|
||||
this.separator = options.separator ?? '\\n';
|
||||
|
||||
this.separator = separator;
|
||||
|
||||
if (this.separator === '\\n') {
|
||||
this.separator = '\n';
|
||||
}
|
||||
|
@ -39,7 +44,7 @@ export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
|||
this.numberOfItems ? this.numberOfItems + ' ' : ''
|
||||
}items separated by`;
|
||||
|
||||
const numberOfExamples = this.numberOfItems;
|
||||
const numberOfExamples = this.numberOfItems ?? 3; // Default number of examples in case numberOfItems is not set
|
||||
|
||||
const examples: string[] = [];
|
||||
for (let i = 1; i <= numberOfExamples; i++) {
|
||||
|
|
|
@ -1313,6 +1313,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,
|
||||
|
|
|
@ -54,22 +54,25 @@ export const mockNodeTypeDescription = ({
|
|||
credentials = [],
|
||||
inputs = [NodeConnectionType.Main],
|
||||
outputs = [NodeConnectionType.Main],
|
||||
properties = [],
|
||||
}: {
|
||||
name?: INodeTypeDescription['name'];
|
||||
version?: INodeTypeDescription['version'];
|
||||
credentials?: INodeTypeDescription['credentials'];
|
||||
inputs?: INodeTypeDescription['inputs'];
|
||||
outputs?: INodeTypeDescription['outputs'];
|
||||
properties?: INodeTypeDescription['properties'];
|
||||
} = {}) =>
|
||||
mock<INodeTypeDescription>({
|
||||
name,
|
||||
displayName: name,
|
||||
description: '',
|
||||
version,
|
||||
defaults: {
|
||||
name,
|
||||
},
|
||||
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
|
||||
properties: [],
|
||||
properties: properties as [],
|
||||
maxNodes: Infinity,
|
||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
||||
inputs,
|
||||
|
|
|
@ -178,9 +178,9 @@ export const useLinter = (
|
|||
message: i18n.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Remove',
|
||||
name: 'Fix',
|
||||
apply(view) {
|
||||
view.dispatch({ changes: { from: start - '.'.length, to: end } });
|
||||
view.dispatch({ changes: { from: start, to: end, insert: 'first()' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -559,6 +559,76 @@ export const useLinter = (
|
|||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lint for `$(variable)` usage where variable is not a string, in both modes.
|
||||
*
|
||||
* $(nodeName) -> <no autofix>
|
||||
*/
|
||||
const isDollarSignWithVariable = (node: Node) =>
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === '$' &&
|
||||
node.arguments.length === 1 &&
|
||||
((node.arguments[0].type !== 'Literal' && node.arguments[0].type !== 'TemplateLiteral') ||
|
||||
(node.arguments[0].type === 'TemplateLiteral' && node.arguments[0].expressions.length > 0));
|
||||
|
||||
type TargetCallNode = RangeNode & {
|
||||
callee: { name: string };
|
||||
arguments: Array<{ type: string }>;
|
||||
};
|
||||
|
||||
walk<TargetCallNode>(ast, isDollarSignWithVariable).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: 'warning',
|
||||
message: i18n.baseText('codeNodeEditor.linter.bothModes.dollarSignVariable'),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lint for $("myNode").item access in runOnceForAllItems mode
|
||||
*
|
||||
* $("myNode").item -> $("myNode").first()
|
||||
*/
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
type DollarItemNode = RangeNode & {
|
||||
property: { name: string; type: string } & RangeNode;
|
||||
};
|
||||
|
||||
const isDollarNodeItemAccess = (node: Node) =>
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.object.type === 'CallExpression' &&
|
||||
node.object.callee.type === 'Identifier' &&
|
||||
node.object.callee.name === '$' &&
|
||||
node.object.arguments.length === 1 &&
|
||||
node.object.arguments[0].type === 'Literal' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'item';
|
||||
|
||||
walk<DollarItemNode>(ast, isDollarNodeItemAccess).forEach((node) => {
|
||||
const [start, end] = getRange(node.property);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: 'warning',
|
||||
message: i18n.baseText('codeNodeEditor.linter.eachItem.preferFirst'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view) {
|
||||
view.dispatch({ changes: { from: start, to: end, insert: 'first()' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return lintings;
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
TRANSFORM_DATA_SUBCATEGORY,
|
||||
|
@ -405,14 +405,14 @@ export function TriggerView() {
|
|||
},
|
||||
},
|
||||
{
|
||||
key: MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
key: CHAT_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
category: [CORE_NODES_CATEGORY],
|
||||
properties: {
|
||||
group: [],
|
||||
name: MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName'),
|
||||
description: i18n.baseText('nodeCreator.triggerHelperPanel.manualChatTriggerDescription'),
|
||||
name: CHAT_TRIGGER_NODE_TYPE,
|
||||
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.chatTriggerDisplayName'),
|
||||
description: i18n.baseText('nodeCreator.triggerHelperPanel.chatTriggerDescription'),
|
||||
icon: 'fa:comments',
|
||||
},
|
||||
},
|
||||
|
|
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">
|
||||
|
|
|
@ -2050,6 +2050,36 @@ describe('useCanvasOperations', () => {
|
|||
expect(workflowsStore.setConnections).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize node data from node type description', () => {
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
const type = SET_NODE_TYPE;
|
||||
const version = 1;
|
||||
const expectedDescription = mockNodeTypeDescription({
|
||||
name: type,
|
||||
version,
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
nodeTypesStore.nodeTypes = { [type]: { [version]: expectedDescription } };
|
||||
|
||||
const workflow = createTestWorkflow({
|
||||
nodes: [createTestNode()],
|
||||
connections: {},
|
||||
});
|
||||
|
||||
const { initializeWorkspace } = useCanvasOperations({ router });
|
||||
initializeWorkspace(workflow);
|
||||
|
||||
expect(workflow.nodes[0].parameters).toEqual({ value: true });
|
||||
});
|
||||
});
|
||||
|
||||
function buildImportNodes() {
|
||||
|
|
|
@ -778,8 +778,8 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
parameters,
|
||||
};
|
||||
|
||||
resolveNodeParameters(nodeData);
|
||||
resolveNodeName(nodeData);
|
||||
resolveNodeParameters(nodeData, nodeTypeDescription);
|
||||
resolveNodeWebhook(nodeData, nodeTypeDescription);
|
||||
|
||||
return nodeData;
|
||||
|
@ -828,10 +828,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
return nodeVersion;
|
||||
}
|
||||
|
||||
function resolveNodeParameters(node: INodeUi) {
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
function resolveNodeParameters(node: INodeUi, nodeTypeDescription: INodeTypeDescription) {
|
||||
const nodeParameters = NodeHelpers.getNodeParameters(
|
||||
nodeType?.properties ?? [],
|
||||
nodeTypeDescription?.properties ?? [],
|
||||
node.parameters,
|
||||
true,
|
||||
false,
|
||||
|
@ -1381,7 +1380,10 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
workflowHelpers.initState(data);
|
||||
|
||||
data.nodes.forEach((node) => {
|
||||
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
|
||||
nodeHelpers.matchCredentials(node);
|
||||
resolveNodeParameters(node, nodeTypeDescription);
|
||||
resolveNodeWebhook(node, nodeTypeDescription);
|
||||
});
|
||||
|
||||
workflowsStore.setNodes(data.nodes);
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -447,7 +447,7 @@
|
|||
"codeNodeEditor.linter.allItems.itemCall": "`item` is a property to access, not a method to call. Did you mean `.item` without brackets?",
|
||||
"codeNodeEditor.linter.allItems.itemMatchingNoArg": "`.itemMatching()` expects an item index to be passed in as its argument.",
|
||||
"codeNodeEditor.linter.allItems.unavailableItem": "Legacy `item` is only available in the 'Run Once for Each Item' mode.",
|
||||
"codeNodeEditor.linter.allItems.unavailableProperty": "`.item` is only available in the 'Run Once for Each Item' mode.",
|
||||
"codeNodeEditor.linter.allItems.unavailableProperty": "`.item` is only available in the 'Run Once for Each Item' mode. Use `.first()` instead.",
|
||||
"codeNodeEditor.linter.allItems.unavailableVar": "is only available in the 'Run Once for Each Item' mode.",
|
||||
"codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
|
||||
"codeNodeEditor.linter.bothModes.directAccess.itemProperty": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
|
||||
|
@ -458,7 +458,9 @@
|
|||
"codeNodeEditor.linter.eachItem.returnArray": "Code doesn't return an object. Array found instead. Please return an object representing the output item",
|
||||
"codeNodeEditor.linter.eachItem.unavailableItems": "Legacy `items` is only available in the 'Run Once for All Items' mode.",
|
||||
"codeNodeEditor.linter.eachItem.unavailableMethod": "Method `$input.{method}()` is only available in the 'Run Once for All Items' mode.",
|
||||
"codeNodeEditor.linter.eachItem.preferFirst": "Prefer `.first()` over `.item` so n8n can optimize execution",
|
||||
"codeNodeEditor.linter.bothModes.syntaxError": "Syntax error",
|
||||
"codeNodeEditor.linter.bothModes.dollarSignVariable": "Use a string literal instead of a variable so n8n can optimize execution.",
|
||||
"codeNodeEditor.askAi.placeholder": "Tell AI what you want the code to achieve. You can reference input data fields using dot notation (e.g. user.email)",
|
||||
"codeNodeEditor.askAi.intro": "Hey AI, generate JavaScript code that...",
|
||||
"codeNodeEditor.askAi.help": "Help",
|
||||
|
@ -891,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",
|
||||
|
@ -1117,6 +1119,8 @@
|
|||
"nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message",
|
||||
"nodeCreator.triggerHelperPanel.manualChatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
|
||||
"nodeCreator.triggerHelperPanel.manualTriggerTag": "Recommended",
|
||||
"nodeCreator.triggerHelperPanel.chatTriggerDisplayName": "On chat message",
|
||||
"nodeCreator.triggerHelperPanel.chatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
|
||||
"nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?",
|
||||
"nodeCreator.triggerHelperPanel.selectATrigger": "What triggers this workflow?",
|
||||
"nodeCreator.triggerHelperPanel.selectATriggerDescription": "A trigger is a step that starts your workflow",
|
||||
|
|
|
@ -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