Merge branch 'master' of github.com:n8n-io/n8n into ado-2808-1

This commit is contained in:
Mutasem Aldmour 2024-11-12 17:06:41 +01:00
commit 9e70cb1b05
No known key found for this signature in database
GPG key ID: 3DFA8122BB7FD6B8
32 changed files with 453 additions and 295 deletions

View file

@ -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');

View file

@ -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')

View file

@ -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') {

View file

@ -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++) {

View file

@ -1313,6 +1313,7 @@ export type ExecutionFilterType = {
export type ExecutionsQueryFilter = {
status?: ExecutionStatus[];
projectId?: string;
workflowId?: string;
finished?: boolean;
waitTill?: boolean;

View file

@ -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,

View file

@ -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,

View file

@ -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;
}

View file

@ -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',

View file

@ -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',
},
},

View 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,
);
});
});

View 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>

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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>

View file

@ -313,4 +313,9 @@ function scrollToActiveCard(): void {
border-radius: 0;
}
}
:deep(.el-checkbox) {
display: flex;
align-items: center;
}
</style>

View file

@ -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();
});
});

View file

@ -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>

View file

@ -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(),
);
});
});
});

View file

@ -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">

View file

@ -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() {

View file

@ -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);

View file

@ -71,6 +71,7 @@ vi.mock('vue-router', async (importOriginal) => {
useRouter: vi.fn().mockReturnValue({
push: vi.fn(),
}),
useRoute: vi.fn(),
};
});

View file

@ -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];

View file

@ -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",

View file

@ -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,

View file

@ -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',
},
];

View file

@ -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 } : {}),
}));

View file

@ -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>

View file

@ -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'));
}

View file

@ -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>

View file

@ -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">