Merge branch 'master' into ai-508-backend-cancel-test-run

# Conflicts:
#	packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts
This commit is contained in:
Eugene Molodkin 2025-01-07 12:45:10 +01:00
commit 80cb4d51af
No known key found for this signature in database
56 changed files with 957 additions and 209 deletions

View file

@ -0,0 +1,234 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = `
{
"codex": {
"categories": [
"AI",
],
"resources": {
"primaryDocumentation": [
{
"url": undefined,
},
],
},
"subcategories": {
"AI": [
"Vector Stores",
"Tools",
"Root Nodes",
],
"Tools": [
"Other Tools",
],
},
},
"credentials": undefined,
"defaults": {
"name": undefined,
},
"description": undefined,
"displayName": undefined,
"group": [
"transform",
],
"icon": undefined,
"iconColor": undefined,
"inputs": "={{
((parameters) => {
const mode = parameters?.mode;
const inputs = [{ displayName: "Embedding", type: "ai_embedding", required: true, maxConnections: 1}]
if (mode === 'retrieve-as-tool') {
return inputs;
}
if (['insert', 'load', 'update'].includes(mode)) {
inputs.push({ displayName: "", type: "main"})
}
if (['insert'].includes(mode)) {
inputs.push({ displayName: "Document", type: "ai_document", required: true, maxConnections: 1})
}
return inputs
})($parameter)
}}",
"name": "mockConstructor",
"outputs": "={{
((parameters) => {
const mode = parameters?.mode ?? 'retrieve';
if (mode === 'retrieve-as-tool') {
return [{ displayName: "Tool", type: "ai_tool"}]
}
if (mode === 'retrieve') {
return [{ displayName: "Vector Store", type: "ai_vectorStore"}]
}
return [{ displayName: "", type: "main"}]
})($parameter)
}}",
"properties": [
{
"default": "retrieve",
"displayName": "Operation Mode",
"name": "mode",
"noDataExpression": true,
"options": [
{
"action": "Get ranked documents from vector store",
"description": "Get many ranked documents from vector store for query",
"name": "Get Many",
"value": "load",
},
{
"action": "Add documents to vector store",
"description": "Insert documents into vector store",
"name": "Insert Documents",
"value": "insert",
},
{
"action": "Retrieve documents for AI processing as Vector Store",
"description": "Retrieve documents from vector store to be used as vector store with AI nodes",
"name": "Retrieve Documents (As Vector Store for AI Agent)",
"outputConnectionType": "ai_vectorStore",
"value": "retrieve",
},
{
"action": "Retrieve documents for AI processing as Tool",
"description": "Retrieve documents from vector store to be used as tool with AI nodes",
"name": "Retrieve Documents (As Tool for AI Agent)",
"outputConnectionType": "ai_tool",
"value": "retrieve-as-tool",
},
],
"type": "options",
},
{
"default": "",
"displayName": "This node must be connected to a vector store retriever. <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='ai_retriever'>Insert one</a>",
"displayOptions": {
"show": {
"mode": [
"retrieve",
],
},
},
"name": "notice",
"type": "notice",
"typeOptions": {
"containerClass": "ndv-connection-hint-notice",
},
},
{
"default": "",
"description": "Name of the vector store",
"displayName": "Name",
"displayOptions": {
"show": {
"mode": [
"retrieve-as-tool",
],
},
},
"name": "toolName",
"placeholder": "e.g. company_knowledge_base",
"required": true,
"type": "string",
"validateType": "string-alphanumeric",
},
{
"default": "",
"description": "Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often",
"displayName": "Description",
"displayOptions": {
"show": {
"mode": [
"retrieve-as-tool",
],
},
},
"name": "toolDescription",
"placeholder": "e.g. undefined",
"required": true,
"type": "string",
"typeOptions": {
"rows": 2,
},
},
{
"default": "",
"description": "Search prompt to retrieve matching documents from the vector store using similarity-based ranking",
"displayName": "Prompt",
"displayOptions": {
"show": {
"mode": [
"load",
],
},
},
"name": "prompt",
"required": true,
"type": "string",
},
{
"default": 4,
"description": "Number of top results to fetch from vector store",
"displayName": "Limit",
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "topK",
"type": "number",
},
{
"default": true,
"description": "Whether or not to include document metadata",
"displayName": "Include Metadata",
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "includeDocumentMetadata",
"type": "boolean",
},
{
"default": "",
"description": "ID of an embedding entry",
"displayName": "ID",
"displayOptions": {
"show": {
"mode": [
"update",
],
},
},
"name": "id",
"required": true,
"type": "string",
},
{
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "loadField",
},
],
"version": 1,
}
`;

View file

@ -49,7 +49,11 @@ describe('createVectorStoreNode', () => {
const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({ const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({
sharedFields: [], sharedFields: [],
insertFields: [], insertFields: [],
loadFields: [], loadFields: [
{
name: 'loadField',
},
],
retrieveFields: [], retrieveFields: [],
updateFields: [], updateFields: [],
getVectorStoreClient: jest.fn().mockReturnValue(vectorStore), getVectorStoreClient: jest.fn().mockReturnValue(vectorStore),
@ -82,6 +86,7 @@ describe('createVectorStoreNode', () => {
const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped; const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped;
// ASSERT // ASSERT
expect(nodeType.description).toMatchSnapshot();
expect(wrappedVectorStore).toEqual(vectorStore); expect(wrappedVectorStore).toEqual(vectorStore);
expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled(); expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled();
}); });

View file

@ -80,10 +80,13 @@ export interface VectorStoreNodeConstructorArgs {
) => Promise<VectorStore>; ) => Promise<VectorStore>;
} }
function transformDescriptionForOperationMode(fields: INodeProperties[], mode: NodeOperationMode) { function transformDescriptionForOperationMode(
fields: INodeProperties[],
mode: NodeOperationMode | NodeOperationMode[],
) {
return fields.map((field) => ({ return fields.map((field) => ({
...field, ...field,
displayOptions: { show: { mode: [mode] } }, displayOptions: { show: { mode: Array.isArray(mode) ? mode : [mode] } },
})); }));
} }
@ -299,7 +302,10 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
}, },
}, },
}, },
...transformDescriptionForOperationMode(args.loadFields ?? [], 'load'), ...transformDescriptionForOperationMode(args.loadFields ?? [], [
'load',
'retrieve-as-tool',
]),
...transformDescriptionForOperationMode(args.retrieveFields ?? [], 'retrieve'), ...transformDescriptionForOperationMode(args.retrieveFields ?? [], 'retrieve'),
...transformDescriptionForOperationMode(args.updateFields ?? [], 'update'), ...transformDescriptionForOperationMode(args.updateFields ?? [], 'update'),
], ],

View file

@ -28,6 +28,7 @@ describe('result validation', () => {
['binary', {}], ['binary', {}],
['pairedItem', {}], ['pairedItem', {}],
['error', {}], ['error', {}],
['index', {}], // temporarily allowed until refactored out
])( ])(
'should not throw an error if the output item has %s key in addition to json', 'should not throw an error if the output item has %s key in addition to json',
(key, value) => { (key, value) => {

View file

@ -4,7 +4,19 @@ import type { INodeExecutionData } from 'n8n-workflow';
import { ValidationError } from './errors/validation-error'; import { ValidationError } from './errors/validation-error';
import { isObject } from './obj-utils'; import { isObject } from './obj-utils';
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']); export const REQUIRED_N8N_ITEM_KEYS = new Set([
'json',
'binary',
'pairedItem',
'error',
/**
* The `index` key was added accidentally to Function, FunctionItem, Gong,
* Execute Workflow, and ToolWorkflowV2, so we need to allow it temporarily.
* Once we stop using it in all nodes, we can stop allowing the `index` key.
*/
'index',
]);
function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) { function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) {
for (const key in item) { for (const key in item) {

View file

@ -87,7 +87,7 @@ export class EnterpriseCredentialsService {
if (credential) { if (credential) {
// Decrypt the data if we found the credential with the `credential:update` // Decrypt the data if we found the credential with the `credential:update`
// scope. // scope.
decryptedData = this.credentialsService.decrypt(credential); decryptedData = this.credentialsService.decrypt(credential, true);
} else { } else {
// Otherwise try to find them with only the `credential:read` scope. In // Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data. // that case we return them without the decrypted data.

View file

@ -542,7 +542,7 @@ export class CredentialsService {
if (sharing) { if (sharing) {
// Decrypt the data if we found the credential with the `credential:update` // Decrypt the data if we found the credential with the `credential:update`
// scope. // scope.
decryptedData = this.decrypt(sharing.credentials); decryptedData = this.decrypt(sharing.credentials, true);
} else { } else {
// Otherwise try to find them with only the `credential:read` scope. In // Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data. // that case we return them without the decrypted data.

View file

@ -9,7 +9,8 @@ import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
// Entity representing a node in a workflow under test, for which data should be mocked during test execution // Entity representing a node in a workflow under test, for which data should be mocked during test execution
export type MockedNodeItem = { export type MockedNodeItem = {
name: string; name?: string;
id: string;
}; };
/** /**

View file

@ -16,6 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
description: z.string().optional(), description: z.string().optional(),
evaluationWorkflowId: z.string().min(1).optional(), evaluationWorkflowId: z.string().min(1).optional(),
annotationTagId: z.string().min(1).optional(), annotationTagId: z.string().min(1).optional(),
mockedNodes: z.array(z.object({ name: z.string() })).optional(), mockedNodes: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
}) })
.strict(); .strict();

View file

@ -121,13 +121,26 @@ export class TestDefinitionService {
relations: ['workflow'], relations: ['workflow'],
}); });
const existingNodeNames = new Set(existingTestDefinition.workflow.nodes.map((n) => n.name)); const existingNodeNames = new Map(
existingTestDefinition.workflow.nodes.map((n) => [n.name, n]),
);
const existingNodeIds = new Map(existingTestDefinition.workflow.nodes.map((n) => [n.id, n]));
attrs.mockedNodes.forEach((node) => { attrs.mockedNodes.forEach((node) => {
if (!existingNodeNames.has(node.name)) { if (!existingNodeIds.has(node.id) || (node.name && !existingNodeNames.has(node.name))) {
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`); throw new BadRequestError(
`Pinned node not found in the workflow: ${node.id} (${node.name})`,
);
} }
}); });
// Update the node names OR node ids if they are not provided
attrs.mockedNodes = attrs.mockedNodes.map((node) => {
return {
id: node.id ?? (node.name && existingNodeNames.get(node.name)?.id),
name: node.name ?? (node.id && existingNodeIds.get(node.id)?.name),
};
});
} }
// Update the test definition // Update the test definition

View file

@ -7,13 +7,24 @@ const wfUnderTestJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
); );
const wfUnderTestRenamedNodesJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
encoding: 'utf-8',
}),
);
const executionDataJson = JSON.parse( const executionDataJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
); );
describe('createPinData', () => { describe('createPinData', () => {
test('should create pin data from past execution data', () => { test('should create pin data from past execution data', () => {
const mockedNodes = ['When clicking Test workflow'].map((name) => ({ name })); const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af',
name: 'When clicking Test workflow',
},
];
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson); const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -25,7 +36,7 @@ describe('createPinData', () => {
}); });
test('should not create pin data for non-existing mocked nodes', () => { test('should not create pin data for non-existing mocked nodes', () => {
const mockedNodes = ['Non-existing node'].map((name) => ({ name })); const mockedNodes = ['non-existing-ID'].map((id) => ({ id }));
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson); const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -33,9 +44,17 @@ describe('createPinData', () => {
}); });
test('should create pin data for all mocked nodes', () => { test('should create pin data for all mocked nodes', () => {
const mockedNodes = ['When clicking Test workflow', 'Edit Fields', 'Code'].map((name) => ({ const mockedNodes = [
name, {
})); id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'When clicking Test workflow'
},
{
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Edit Fields'
},
{
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
},
];
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson); const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -53,4 +72,33 @@ describe('createPinData', () => {
expect(pinData).toEqual({}); expect(pinData).toEqual({});
}); });
test('should create pin data for all mocked nodes with renamed nodes', () => {
const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'Manual Run'
},
{
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Set Attribute'
},
{
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
},
];
const pinData = createPinData(
wfUnderTestRenamedNodesJson,
mockedNodes,
executionDataJson,
wfUnderTestJson, // Pass original workflow JSON as pastWorkflowData
);
expect(pinData).toEqual(
expect.objectContaining({
'Manual Run': expect.anything(),
'Set Attribute': expect.anything(),
Code: expect.anything(),
}),
);
});
}); });

View file

@ -0,0 +1,78 @@
{
"name": "Workflow Under Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-80, 0],
"id": "72256d90-3a67-4e29-b032-47df4e5768af",
"name": "Manual Run"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "acfeecbe-443c-4220-b63b-d44d69216902",
"name": "foo",
"value": "bar",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [140, 0],
"id": "319f29bc-1dd4-4122-b223-c584752151a4",
"name": "Set Attribute"
},
{
"parameters": {
"jsCode": "for (const item of $input.all()) {\n item.json.random = Math.random();\n}\n\nreturn $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [380, 0],
"id": "d2474215-63af-40a4-a51e-0ea30d762621",
"name": "Code"
}
],
"connections": {
"Manual Run": {
"main": [
[
{
"node": "Set attribute",
"type": "main",
"index": 0
}
]
]
},
"Set attribute": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Wait": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -27,6 +27,12 @@ const wfUnderTestJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
); );
const wfUnderTestRenamedNodesJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
encoding: 'utf-8',
}),
);
const wfEvaluationJson = JSON.parse( const wfEvaluationJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
); );
@ -60,6 +66,7 @@ const executionMocks = [
status: 'success', status: 'success',
executionData: { executionData: {
data: stringify(executionDataJson), data: stringify(executionDataJson),
workflowData: wfUnderTestJson,
}, },
}), }),
mock<ExecutionEntity>({ mock<ExecutionEntity>({
@ -68,6 +75,7 @@ const executionMocks = [
status: 'success', status: 'success',
executionData: { executionData: {
data: stringify(executionDataJson), data: stringify(executionDataJson),
workflowData: wfUnderTestRenamedNodesJson,
}, },
}), }),
]; ];
@ -252,7 +260,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({ mock<TestDefinition>({
workflowId: 'workflow-under-test-id', workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id', evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [{ name: 'When clicking Test workflow' }], mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
}), }),
); );
@ -349,7 +357,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({ mock<TestDefinition>({
workflowId: 'workflow-under-test-id', workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id', evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [{ name: 'When clicking Test workflow' }], mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
}), }),
); );

View file

@ -1,13 +1,15 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { parse } from 'flatted'; import { parse } from 'flatted';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
IRun, IRun,
IRunData, IRunData,
IRunExecutionData, IRunExecutionData,
IWorkflowBase,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
import assert from 'node:assert'; import assert from 'node:assert';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
@ -97,6 +99,7 @@ export class TestRunnerService {
private async runTestCase( private async runTestCase(
workflow: WorkflowEntity, workflow: WorkflowEntity,
pastExecutionData: IRunExecutionData, pastExecutionData: IRunExecutionData,
pastExecutionWorkflowData: IWorkflowBase,
mockedNodes: MockedNodeItem[], mockedNodes: MockedNodeItem[],
userId: string, userId: string,
abortSignal: AbortSignal, abortSignal: AbortSignal,
@ -107,7 +110,12 @@ export class TestRunnerService {
} }
// Create pin data from the past execution data // Create pin data from the past execution data
const pinData = createPinData(workflow, mockedNodes, pastExecutionData); const pinData = createPinData(
workflow,
mockedNodes,
pastExecutionData,
pastExecutionWorkflowData,
);
// Prepare the data to run the workflow // Prepare the data to run the workflow
const data: IWorkflowExecutionDataProcess = { const data: IWorkflowExecutionDataProcess = {
@ -269,7 +277,7 @@ export class TestRunnerService {
const testCaseExecution = await this.runTestCase( const testCaseExecution = await this.runTestCase(
workflow, workflow,
executionData, executionData,
test.mockedNodes, pastExecution.executionData.workflowData,test.mockedNodes,
user.id, user.id,
abortSignal, abortSignal,
); );

View file

@ -1,4 +1,5 @@
import type { IRunExecutionData, IPinData } from 'n8n-workflow'; import assert from 'assert';
import type { IRunExecutionData, IPinData, IWorkflowBase } from 'n8n-workflow';
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee'; import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
@ -13,16 +14,33 @@ export function createPinData(
workflow: WorkflowEntity, workflow: WorkflowEntity,
mockedNodes: MockedNodeItem[], mockedNodes: MockedNodeItem[],
executionData: IRunExecutionData, executionData: IRunExecutionData,
pastWorkflowData?: IWorkflowBase,
) { ) {
const pinData = {} as IPinData; const pinData = {} as IPinData;
const workflowNodeNames = new Set(workflow.nodes.map((node) => node.name)); const workflowNodeIds = new Map(workflow.nodes.map((node) => [node.id, node.name]));
// If the past workflow data is provided, use it to create a map between node IDs and node names
const pastWorkflowNodeIds = new Map<string, string>();
if (pastWorkflowData) {
for (const node of pastWorkflowData.nodes) {
pastWorkflowNodeIds.set(node.id, node.name);
}
}
for (const mockedNode of mockedNodes) { for (const mockedNode of mockedNodes) {
if (workflowNodeNames.has(mockedNode.name)) { assert(mockedNode.id, 'Mocked node ID is missing');
const nodeData = executionData.resultData.runData[mockedNode.name];
const nodeName = workflowNodeIds.get(mockedNode.id);
// If mocked node is still present in the workflow
if (nodeName) {
// Try to restore node name from past execution data (it might have been renamed between past execution and up-to-date workflow)
const pastNodeName = pastWorkflowNodeIds.get(mockedNode.id) ?? nodeName;
const nodeData = executionData.resultData.runData[pastNodeName];
if (nodeData?.[0]?.data?.main?.[0]) { if (nodeData?.[0]?.data?.main?.[0]) {
pinData[mockedNode.name] = nodeData[0]?.data?.main?.[0]; pinData[nodeName] = nodeData[0]?.data?.main?.[0];
} }
} }
} }

View file

@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import config from '@/config'; import config from '@/config';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation'; import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
@ -555,6 +556,22 @@ describe('GET /credentials/:id', () => {
expect(secondCredential.data).toBeDefined(); expect(secondCredential.data).toBeDefined();
}); });
test('should not redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: owner,
});
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true });
validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled();
});
test('should retrieve non-owned cred for owner', async () => { test('should retrieve non-owned cred for owner', async () => {
const [member1, member2] = await createManyUsers(2, { const [member1, member2] = await createManyUsers(2, {
role: 'global:member', role: 'global:member',

View file

@ -4,6 +4,7 @@ import type { Scope } from '@sentry/node';
import { Credentials } from 'n8n-core'; import { Credentials } from 'n8n-core';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
@ -1272,6 +1273,23 @@ describe('GET /credentials/:id', () => {
expect(secondResponse.body.data.data).toBeDefined(); expect(secondResponse.body.data.data).toBeDefined();
}); });
test('should not redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true });
validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled();
});
test('should retrieve owned cred for member', async () => { test('should retrieve owned cred for member', async () => {
const savedCredential = await saveCredential(randomCredentialPayload(), { const savedCredential = await saveCredential(randomCredentialPayload(), {
user: member, user: member,

View file

@ -405,13 +405,14 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [ mockedNodes: [
{ {
id: 'uuid-1234',
name: 'Schedule Trigger', name: 'Schedule Trigger',
}, },
], ],
}); });
expect(resp.statusCode).toBe(200); expect(resp.statusCode).toBe(200);
expect(resp.body.data.mockedNodes).toEqual([{ name: 'Schedule Trigger' }]); expect(resp.body.data.mockedNodes).toEqual([{ id: 'uuid-1234', name: 'Schedule Trigger' }]);
}); });
test('should return error if pinned nodes are invalid', async () => { test('should return error if pinned nodes are invalid', async () => {

View file

@ -34,7 +34,11 @@ const classes = computed(() => ({
<slot name="footer" /> <slot name="footer" />
</div> </div>
</div> </div>
<div v-if="$slots.append" data-test-id="card-append" :class="$style.append"> <div
v-if="$slots.append"
data-test-id="card-append"
:class="[$style.append, 'n8n-card-append']"
>
<slot name="append" /> <slot name="append" />
</div> </div>
</div> </div>
@ -45,7 +49,7 @@ const classes = computed(() => ({
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
border: var(--border-base); border: var(--border-base);
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
padding: var(--spacing-s); padding: var(--card--padding, var(--spacing-s));
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
@ -101,5 +105,6 @@ const classes = computed(() => ({
display: flex; display: flex;
align-items: center; align-items: center;
cursor: default; cursor: default;
width: var(--card--append--width, unset);
} }
</style> </style>

View file

@ -75,6 +75,38 @@ describe('useDeviceSupport()', () => {
}); });
}); });
describe('isMobileDevice', () => {
it('should be true for iOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'iphone' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for Android user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'android' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be false for non-mobile user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(false);
});
it('should be true for iPad user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipad' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for iPod user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipod' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
});
describe('isCtrlKeyPressed()', () => { describe('isCtrlKeyPressed()', () => {
it('should return true for metaKey press on macOS', () => { it('should return true for metaKey press on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' }); Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });

View file

@ -12,12 +12,16 @@ export function useDeviceSupport() {
!window.matchMedia('(any-pointer: fine)').matches, !window.matchMedia('(any-pointer: fine)').matches,
); );
const userAgent = ref(navigator.userAgent.toLowerCase()); const userAgent = ref(navigator.userAgent.toLowerCase());
const isMacOs = ref(
userAgent.value.includes('macintosh') || const isIOs = ref(
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipad') || userAgent.value.includes('ipad') ||
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipod'), userAgent.value.includes('ipod'),
); );
const isAndroidOs = ref(userAgent.value.includes('android'));
const isMacOs = ref(userAgent.value.includes('macintosh') || isIOs.value);
const isMobileDevice = ref(isIOs.value || isAndroidOs.value);
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control'); const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean { function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
@ -30,7 +34,10 @@ export function useDeviceSupport() {
return { return {
userAgent: userAgent.value, userAgent: userAgent.value,
isTouchDevice: isTouchDevice.value, isTouchDevice: isTouchDevice.value,
isAndroidOs: isAndroidOs.value,
isIOs: isIOs.value,
isMacOs: isMacOs.value, isMacOs: isMacOs.value,
isMobileDevice: isMobileDevice.value,
controlKeyCode: controlKeyCode.value, controlKeyCode: controlKeyCode.value,
isCtrlKeyPressed, isCtrlKeyPressed,
}; };

View file

@ -5,6 +5,7 @@
@use './base.scss'; @use './base.scss';
@use './pagination.scss'; @use './pagination.scss';
@use './dialog.scss'; @use './dialog.scss';
@use './display.scss';
// @use "./autocomplete.scss"; // @use "./autocomplete.scss";
@use './dropdown.scss'; @use './dropdown.scss';
@use './dropdown-menu.scss'; @use './dropdown-menu.scss';

View file

@ -0,0 +1,20 @@
@use '../common/var';
@mixin breakpoint($name) {
@if map-has-key(var.$breakpoints-spec, $name) {
$query: map-get(var.$breakpoints-spec, $name);
$media-query: '';
@each $key, $value in $query {
$media-query: '#{$media-query} and (#{$key}: #{$value})';
}
$media-query: unquote(str-slice($media-query, 6)); // Remove the initial ' and '
@media screen and #{$media-query} {
@content;
}
} @else {
@error "No breakpoint named `#{$name}` found in `$breakpoints-spec`.";
}
}

View file

@ -0,0 +1,6 @@
@forward 'breakpoints';
@forward 'button';
@forward 'config';
@forward 'function';
@forward 'mixins';
@forward 'utils';

View file

@ -192,6 +192,8 @@ watch(defaultLocale, (newLocale) => {
.header { .header {
grid-area: header; grid-area: header;
z-index: var(--z-index-app-header); z-index: var(--z-index-app-header);
min-width: 0;
min-height: 0;
} }
.sidebar { .sidebar {

View file

@ -162,6 +162,7 @@ function moveResource() {
<template #append> <template #append>
<div :class="$style.cardActions" @click.stop> <div :class="$style.cardActions" @click.stop>
<ProjectCardBadge <ProjectCardBadge
:class="$style.cardBadge"
:resource="data" :resource="data"
:resource-type="ResourceType.Credential" :resource-type="ResourceType.Credential"
:resource-type-label="resourceTypeLabel" :resource-type-label="resourceTypeLabel"
@ -180,9 +181,10 @@ function moveResource() {
<style lang="scss" module> <style lang="scss" module>
.cardLink { .cardLink {
--card--padding: 0 0 0 var(--spacing-s);
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
cursor: pointer; cursor: pointer;
padding: 0 0 0 var(--spacing-s);
align-items: stretch; align-items: stretch;
&:hover { &:hover {
@ -215,4 +217,22 @@ function moveResource() {
padding: 0 var(--spacing-s) 0 0; padding: 0 var(--spacing-s) 0 0;
cursor: default; cursor: default;
} }
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing-s) var(--spacing-s);
--card--append--width: 100%;
flex-wrap: wrap;
}
.cardActions {
width: 100%;
padding: 0;
}
.cardBadge {
margin-right: auto;
}
}
</style> </style>

View file

@ -1106,7 +1106,7 @@ function resetCredentialData(): void {
</template> </template>
<template #content> <template #content>
<div :class="$style.container" data-test-id="credential-edit-dialog"> <div :class="$style.container" data-test-id="credential-edit-dialog">
<div :class="$style.sidebar"> <div v-if="!isEditingManagedCredential" :class="$style.sidebar">
<n8n-menu <n8n-menu
mode="tabs" mode="tabs"
:items="sidebarItems" :items="sidebarItems"

View file

@ -3,6 +3,8 @@ import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants'; import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants';
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
import { useCredentialsStore } from '@/stores/credentials.store';
import type { ICredentialsResponse } from '@/Interface';
vi.mock('@/permissions', () => ({ vi.mock('@/permissions', () => ({
getResourcePermissions: vi.fn(() => ({ getResourcePermissions: vi.fn(() => ({
@ -23,6 +25,10 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
}, },
[STORES.SETTINGS]: { [STORES.SETTINGS]: {
settings: { settings: {
enterprise: {
sharing: true,
externalSecrets: false,
},
templates: { templates: {
host: '', host: '',
}, },
@ -67,4 +73,54 @@ describe('CredentialEdit', () => {
}); });
await retry(() => expect(queryByTestId('credential-save-button')).not.toBeInTheDocument()); await retry(() => expect(queryByTestId('credential-save-button')).not.toBeInTheDocument());
}); });
test('hides menu item when credential is managed', async () => {
const credentialsStore = useCredentialsStore();
credentialsStore.state.credentials = {
'123': {
isManaged: false,
} as ICredentialsResponse,
};
const { queryByText } = renderComponent({
props: {
activeId: '123', // credentialId will be set to this value in edit mode
isTesting: false,
isSaving: false,
hasUnsavedChanges: false,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'edit',
},
});
await retry(() => expect(queryByText('Details')).toBeInTheDocument());
await retry(() => expect(queryByText('Connection')).toBeInTheDocument());
await retry(() => expect(queryByText('Sharing')).toBeInTheDocument());
});
test('shows menu item when credential is not managed', async () => {
const credentialsStore = useCredentialsStore();
credentialsStore.state.credentials = {
'123': {
isManaged: true,
} as ICredentialsResponse,
};
const { queryByText } = renderComponent({
props: {
activeId: '123', // credentialId will be set to this value in edit mode
isTesting: false,
isSaving: false,
hasUnsavedChanges: false,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'edit',
},
});
await retry(() => expect(queryByText('Details')).not.toBeInTheDocument());
await retry(() => expect(queryByText('Connection')).not.toBeInTheDocument());
await retry(() => expect(queryByText('Sharing')).not.toBeInTheDocument());
});
}); });

View file

@ -220,7 +220,7 @@ function hideGithubButton() {
@update:model-value="onTabSelected" @update:model-value="onTabSelected"
/> />
</div> </div>
<div v-if="showGitHubButton" class="github-button"> <div v-if="showGitHubButton" class="github-button hidden-sm-and-down">
<div class="github-button-container"> <div class="github-button-container">
<GithubButton <GithubButton
href="https://github.com/n8n-io/n8n" href="https://github.com/n8n-io/n8n"
@ -264,6 +264,7 @@ function hideGithubButton() {
font-size: 0.9em; font-size: 0.9em;
font-weight: 400; font-weight: 400;
padding: var(--spacing-xs) var(--spacing-m); padding: var(--spacing-xs) var(--spacing-m);
overflow: auto;
} }
.github-button { .github-button {

View file

@ -800,6 +800,8 @@ $--header-spacing: 20px;
.name { .name {
color: $custom-font-dark; color: $custom-font-dark;
font-size: 15px; font-size: 15px;
display: block;
min-width: 150px;
} }
.activator { .activator {
@ -807,7 +809,6 @@ $--header-spacing: 20px;
font-weight: 400; font-weight: 400;
font-size: 13px; font-size: 13px;
line-height: $--text-line-height; line-height: $--text-line-height;
display: flex;
align-items: center; align-items: center;
> span { > span {
@ -845,24 +846,24 @@ $--header-spacing: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
flex-wrap: wrap; flex-wrap: nowrap;
} }
</style> </style>
<style module lang="scss"> <style module lang="scss">
.container { .container {
position: relative; position: relative;
top: -1px;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
} }
.group { .group {
display: flex; display: flex;
gap: var(--spacing-xs); gap: var(--spacing-xs);
} }
.hiddenInput { .hiddenInput {
display: none; display: none;
} }

View file

@ -13,6 +13,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { DRAG_EVENT_DATA_KEY } from '@/constants'; import { DRAG_EVENT_DATA_KEY } from '@/constants';
import { useAssistantStore } from '@/stores/assistant.store'; import { useAssistantStore } from '@/stores/assistant.store';
import N8nIconButton from 'n8n-design-system/components/N8nIconButton/IconButton.vue';
export interface Props { export interface Props {
active?: boolean; active?: boolean;
@ -145,6 +146,14 @@ onBeforeUnmount(() => {
[$style.active]: showScrim, [$style.active]: showScrim,
}" }"
/> />
<N8nIconButton
v-if="active"
:class="$style.close"
type="secondary"
icon="times"
aria-label="Close Node Creator"
@click="emit('closeNodeCreator')"
/>
<SlideTransition> <SlideTransition>
<div <div
v-if="active" v-if="active"
@ -168,13 +177,14 @@ onBeforeUnmount(() => {
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
.nodeCreator { .nodeCreator {
--node-creator-width: #{$node-creator-width};
--node-icon-color: var(--color-text-base); --node-icon-color: var(--color-text-base);
position: fixed; position: fixed;
top: $header-height; top: $header-height;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: var(--z-index-node-creator); z-index: var(--z-index-node-creator);
width: $node-creator-width; width: var(--node-creator-width);
color: $node-creator-text-color; color: $node-creator-text-color;
} }
@ -194,4 +204,24 @@ onBeforeUnmount(() => {
opacity: 0.7; opacity: 0.7;
} }
} }
.close {
position: absolute;
z-index: calc(var(--z-index-node-creator) + 1);
top: var(--spacing-xs);
right: var(--spacing-xs);
background: transparent;
border: 0;
display: none;
}
@media screen and (max-width: #{$node-creator-width + $sidebar-width}) {
.nodeCreator {
--node-creator-width: calc(100vw - #{$sidebar-width});
}
.close {
display: inline-flex;
}
}
</style> </style>

View file

@ -260,7 +260,7 @@ function onBackButton() {
height: 100%; height: 100%;
background-color: $node-creator-background-color; background-color: $node-creator-background-color;
--color-background-node-icon-badge: var(--color-background-xlight); --color-background-node-icon-badge: var(--color-background-xlight);
width: 385px; width: var(--node-creator-width);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -303,6 +303,7 @@ function onBackButton() {
line-height: 24px; line-height: 24px;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
font-size: var(--font-size-l); font-size: var(--font-size-l);
margin: 0;
.hasBg & { .hasBg & {
font-size: var(--font-size-s-m); font-size: var(--font-size-s-m);

View file

@ -126,7 +126,7 @@ const badgeTooltip = computed(() => {
</script> </script>
<template> <template>
<N8nTooltip :disabled="!badgeTooltip" placement="top"> <N8nTooltip :disabled="!badgeTooltip" placement="top">
<div class="mr-xs"> <div :class="$style.wrapper" v-bind="$attrs">
<N8nBadge <N8nBadge
v-if="badgeText" v-if="badgeText"
:class="$style.badge" :class="$style.badge"
@ -153,6 +153,10 @@ const badgeTooltip = computed(() => {
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.wrapper {
margin-right: var(--spacing-xs);
}
.badge { .badge {
padding: var(--spacing-4xs) var(--spacing-2xs); padding: var(--spacing-4xs) var(--spacing-2xs);
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);

View file

@ -106,10 +106,10 @@ const onSelect = (action: string) => {
<template> <template>
<div> <div>
<div :class="[$style.projectHeader]"> <div :class="$style.projectHeader">
<div :class="[$style.projectDetails]"> <div :class="$style.projectDetails">
<ProjectIcon :icon="headerIcon" :border-less="true" size="medium" /> <ProjectIcon :icon="headerIcon" :border-less="true" size="medium" />
<div> <div :class="$style.headerActions">
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading> <N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
<N8nText color="text-light"> <N8nText color="text-light">
<slot name="subtitle"> <slot name="subtitle">
@ -147,7 +147,8 @@ const onSelect = (action: string) => {
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.projectHeader { .projectHeader,
.projectDescription {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -163,4 +164,16 @@ const onSelect = (action: string) => {
.actions { .actions {
padding: var(--spacing-2xs) 0 var(--spacing-l); padding: var(--spacing-2xs) 0 var(--spacing-l);
} }
@include mixins.breakpoint('xs-only') {
.projectHeader {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.headerActions {
margin-left: auto;
}
}
</style> </style>

View file

@ -122,7 +122,7 @@ const showAddFirstProject = computed(
}, },
]" ]"
:disabled="isCreatingProject" :disabled="isCreatingProject"
type="tertiary" type="secondary"
icon="plus" icon="plus"
data-test-id="add-first-project-button" data-test-id="add-first-project-button"
@click="globalEntityCreation.createProject" @click="globalEntityCreation.createProject"
@ -187,7 +187,6 @@ const showAddFirstProject = computed(
} }
.addFirstProjectBtn { .addFirstProjectBtn {
border: 1px solid var(--color-background-dark);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
padding: var(--spacing-3xs); padding: var(--spacing-3xs);
margin: 0 var(--spacing-m) var(--spacing-m); margin: 0 var(--spacing-m) var(--spacing-m);

View file

@ -187,6 +187,28 @@ describe('RunData', () => {
expect(pinDataButton).toBeDisabled(); expect(pinDataButton).toBeDisabled();
}); });
it('should render callout when data is pinned in output panel', async () => {
const { getByTestId } = render({
defaultRunItems: [],
displayMode: 'table',
pinnedData: [{ json: { name: 'Test' } }],
paneType: 'output',
});
const pinnedDataCallout = getByTestId('ndv-pinned-data-callout');
expect(pinnedDataCallout).toBeInTheDocument();
});
it('should not render callout when data is pinned in input panel', async () => {
const { queryByTestId } = render({
defaultRunItems: [],
displayMode: 'table',
pinnedData: [{ json: { name: 'Test' } }],
paneType: 'input',
});
const pinnedDataCallout = queryByTestId('ndv-pinned-data-callout');
expect(pinnedDataCallout).not.toBeInTheDocument();
});
it('should enable pin data button when data is not pinned', async () => { it('should enable pin data button when data is not pinned', async () => {
const { getByTestId } = render({ const { getByTestId } = render({
defaultRunItems: [{ json: { name: 'Test' } }], defaultRunItems: [{ json: { name: 'Test' } }],

View file

@ -1277,10 +1277,16 @@ defineExpose({ enterEditMode });
<template> <template>
<div :class="['run-data', $style.container]" @mouseover="activatePane"> <div :class="['run-data', $style.container]" @mouseover="activatePane">
<N8nCallout <N8nCallout
v-if="pinnedData.hasData.value && !editMode.enabled && !isProductionExecutionPreview" v-if="
!isPaneTypeInput &&
pinnedData.hasData.value &&
!editMode.enabled &&
!isProductionExecutionPreview
"
theme="secondary" theme="secondary"
icon="thumbtack" icon="thumbtack"
:class="$style.pinnedDataCallout" :class="$style.pinnedDataCallout"
data-test-id="ndv-pinned-data-callout"
> >
{{ i18n.baseText('runData.pindata.thisDataIsPinned') }} {{ i18n.baseText('runData.pindata.thisDataIsPinned') }}
<span v-if="!isReadOnlyRoute && !readOnlyEnv" class="ml-4xs"> <span v-if="!isReadOnlyRoute && !readOnlyEnv" class="ml-4xs">

View file

@ -268,6 +268,7 @@ function moveResource() {
<template #append> <template #append>
<div :class="$style.cardActions" @click.stop> <div :class="$style.cardActions" @click.stop>
<ProjectCardBadge <ProjectCardBadge
:class="$style.cardBadge"
:resource="data" :resource="data"
:resource-type="ResourceType.Workflow" :resource-type="ResourceType.Workflow"
:resource-type-label="resourceTypeLabel" :resource-type-label="resourceTypeLabel"
@ -330,4 +331,22 @@ function moveResource() {
padding: 0 var(--spacing-s) 0 0; padding: 0 var(--spacing-s) 0 0;
cursor: default; cursor: default;
} }
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing-s) var(--spacing-s);
--card--append--width: 100%;
flex-direction: column;
}
.cardActions {
width: 100%;
padding: 0 var(--spacing-s) var(--spacing-s);
}
.cardBadge {
margin-right: auto;
}
}
</style> </style>

View file

@ -7,105 +7,7 @@ exports[`InputPanel > should render 1`] = `
data-test-id="ndv-input-panel" data-test-id="ndv-input-panel"
data-v-2e5cd75c="" data-v-2e5cd75c=""
> >
<div <!--v-if-->
class="n8n-callout callout secondary round pinnedDataCallout"
data-v-2e5cd75c=""
role="alert"
>
<div
class="messageSection"
>
<div
class="icon"
>
<span
class="n8n-text compact size-medium regular n8n-icon n8n-icon"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-thumbtack fa-w-12 medium"
data-icon="thumbtack"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 384 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
d="M298.028 214.267L285.793 96H328c13.255 0 24-10.745 24-24V24c0-13.255-10.745-24-24-24H56C42.745 0 32 10.745 32 24v48c0 13.255 10.745 24 24 24h42.207L85.972 214.267C37.465 236.82 0 277.261 0 328c0 13.255 10.745 24 24 24h136v104.007c0 1.242.289 2.467.845 3.578l24 48c2.941 5.882 11.364 5.893 14.311 0l24-48a8.008 8.008 0 0 0 .845-3.578V352h136c13.255 0 24-10.745 24-24-.001-51.183-37.983-91.42-85.973-113.733z"
fill="currentColor"
/>
</svg>
</span>
</div>
<span
class="n8n-text size-small regular"
>
This data is pinned.
<span
class="ml-4xs"
data-v-2e5cd75c=""
>
<a
class="n8n-link"
data-test-id="ndv-unpin-data"
data-v-2e5cd75c=""
target="_blank"
>
<span
class="secondary-underline"
>
<span
class="n8n-text size-small bold"
>
Unpin
</span>
</span>
</a>
</span>
</span>
 
</div>
<a
class="n8n-link"
data-v-2e5cd75c=""
href="https://docs.n8n.io/data/data-pinning/"
target="_blank"
>
<span
class="secondary-underline"
>
<span
class="n8n-text size-small bold"
>
Learn more
</span>
</span>
</a>
</div>
<!--v-if--> <!--v-if-->
<div <div
class="header" class="header"

View file

@ -93,7 +93,7 @@ const props = withDefaults(
}, },
); );
const { controlKeyCode } = useDeviceSupport(); const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const vueFlow = useVueFlow({ id: props.id, deleteKeyCode: null }); const vueFlow = useVueFlow({ id: props.id, deleteKeyCode: null });
const { const {
@ -143,9 +143,10 @@ const disableKeyBindings = computed(() => !props.keyBindings);
/** /**
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
*/ */
const panningKeyCode = ref<string[]>([' ', controlKeyCode]);
const panningMouseButton = ref<number[]>([1]); const panningKeyCode = ref<string[] | true>(isMobileDevice ? true : [' ', controlKeyCode]);
const selectionKeyCode = ref<true | null>(true); const panningMouseButton = ref<number[] | true>(isMobileDevice ? true : [1]);
const selectionKeyCode = ref<string | true | null>(isMobileDevice ? 'Shift' : true);
onKeyDown(panningKeyCode.value, () => { onKeyDown(panningKeyCode.value, () => {
selectionKeyCode.value = null; selectionKeyCode.value = null;

View file

@ -483,6 +483,10 @@ const goToUpgrade = () => {
width: 100%; width: 100%;
padding: var(--spacing-l) var(--spacing-2xl) 0; padding: var(--spacing-l) var(--spacing-2xl) 0;
max-width: var(--content-container-width); max-width: var(--content-container-width);
@include mixins.breakpoint('xs-only') {
padding: var(--spacing-xs) var(--spacing-xs) 0;
}
} }
.execList { .execList {

View file

@ -129,4 +129,14 @@ onBeforeRouteLeave(async (to, _, next) => {
.content { .content {
flex: 1; flex: 1;
} }
@include mixins.breakpoint('sm-and-down') {
.container {
flex-direction: column;
}
.content {
flex: 1 1 50%;
}
}
</style> </style>

View file

@ -265,6 +265,7 @@ const goToUpgrade = () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
position: relative;
} }
.heading { .heading {
@ -314,9 +315,10 @@ const goToUpgrade = () => {
bottom: 0; bottom: 0;
margin-left: calc(-1 * var(--spacing-l)); margin-left: calc(-1 * var(--spacing-l));
border-top: var(--border-base); border-top: var(--border-base);
width: 100%;
& > div { & > div {
width: 309px; width: 100%;
background-color: var(--color-background-light); background-color: var(--color-background-light);
margin-top: 0 !important; margin-top: 0 !important;
} }

View file

@ -99,10 +99,16 @@ onBeforeMount(async () => {
:class="$style['filter-button']" :class="$style['filter-button']"
data-test-id="resources-list-filters-trigger" data-test-id="resources-list-filters-trigger"
> >
<n8n-badge v-show="filtersLength > 0" theme="primary" class="mr-4xs"> <n8n-badge
v-show="filtersLength > 0"
:class="$style['filter-button-count']"
theme="primary"
>
{{ filtersLength }} {{ filtersLength }}
</n8n-badge> </n8n-badge>
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }} <span :class="$style['filter-button-text']">
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
</span>
</n8n-button> </n8n-button>
</template> </template>
<div :class="$style['filters-dropdown']" data-test-id="resources-list-filters-dropdown"> <div :class="$style['filters-dropdown']" data-test-id="resources-list-filters-dropdown">
@ -139,6 +145,25 @@ onBeforeMount(async () => {
.filter-button { .filter-button {
height: 40px; height: 40px;
align-items: center; align-items: center;
.filter-button-count {
margin-right: var(--spacing-4xs);
@include mixins.breakpoint('xs-only') {
margin-right: 0;
}
}
@media screen and (max-width: 480px) {
.filter-button-text {
text-indent: -10000px;
}
// Remove icon margin when the "Filters" text is hidden
:global(span + span) {
margin: 0;
}
}
} }
.filters-dropdown { .filters-dropdown {

View file

@ -17,6 +17,10 @@
box-sizing: border-box; box-sizing: border-box;
align-content: start; align-content: start;
padding: var(--spacing-l) var(--spacing-2xl) 0; padding: var(--spacing-l) var(--spacing-2xl) 0;
@include mixins.breakpoint('sm-and-down') {
padding: var(--spacing-s) var(--spacing-s) 0;
}
} }
.content { .content {

View file

@ -475,6 +475,7 @@ onMounted(async () => {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%;
} }
.filters { .filters {
@ -483,10 +484,24 @@ onMounted(async () => {
grid-auto-columns: max-content; grid-auto-columns: max-content;
gap: var(--spacing-2xs); gap: var(--spacing-2xs);
align-items: center; align-items: center;
width: 100%;
@include mixins.breakpoint('xs-only') {
grid-template-columns: 1fr auto;
grid-auto-flow: row;
> *:last-child {
grid-column: auto;
}
}
} }
.search { .search {
max-width: 240px; max-width: 240px;
@include mixins.breakpoint('sm-and-down') {
max-width: 100%;
}
} }
.listWrapper { .listWrapper {
@ -497,6 +512,10 @@ onMounted(async () => {
.sort-and-filter { .sort-and-filter {
white-space: nowrap; white-space: nowrap;
@include mixins.breakpoint('sm-and-down') {
width: 100%;
}
} }
.datatable { .datatable {

View file

@ -2557,7 +2557,7 @@
"projects.menu.overview": "Overview", "projects.menu.overview": "Overview",
"projects.menu.title": "Projects", "projects.menu.title": "Projects",
"projects.menu.personal": "Personal", "projects.menu.personal": "Personal",
"projects.menu.addFirstProject": "Add first project", "projects.menu.addFirstProject": "Add project",
"projects.settings": "Project settings", "projects.settings": "Project settings",
"projects.settings.newProjectName": "My project", "projects.settings.newProjectName": "My project",
"projects.settings.iconPicker.button.tooltip": "Choose project icon", "projects.settings.iconPicker.button.tooltip": "Choose project icon",

View file

@ -56,6 +56,10 @@
fill: var(--color-foreground-dark); fill: var(--color-foreground-dark);
opacity: 0.2; opacity: 0.2;
} }
@include mixins.breakpoint('xs-only') {
display: none;
}
} }
/** /**
@ -100,3 +104,20 @@
.vue-flow__edge-label.selected { .vue-flow__edge-label.selected {
z-index: 1 !important; z-index: 1 !important;
} }
/**
* Controls
*/
.vue-flow__controls {
margin: var(--spacing-s);
@include mixins.breakpoint('xs-only') {
max-width: calc(100% - 3 * var(--spacing-s) - var(--spacing-2xs));
overflow: auto;
margin-left: 0;
margin-right: 0;
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
}
}

View file

@ -1772,11 +1772,13 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
bottom: var(--spacing-l); bottom: var(--spacing-s);
width: auto; width: auto;
@media (max-width: $breakpoint-2xs) { @include mixins.breakpoint('sm-only') {
bottom: 150px; left: auto;
right: var(--spacing-s);
transform: none;
} }
button { button {
@ -1788,6 +1790,17 @@ onBeforeUnmount(() => {
&:first-child { &:first-child {
margin: 0; margin: 0;
} }
@include mixins.breakpoint('xs-only') {
text-indent: -10000px;
width: 42px;
height: 42px;
padding: 0;
span {
margin: 0;
}
}
} }
} }

View file

@ -85,7 +85,11 @@ export default mergeConfig(
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: '\n@use "@/n8n-theme-variables.scss" as *;\n', additionalData: [
'',
'@use "@/n8n-theme-variables.scss" as *;',
'@use "n8n-design-system/css/mixins" as mixins;',
].join('\n'),
}, },
}, },
}, },

View file

@ -22,7 +22,19 @@ export interface SandboxContext extends IWorkflowDataProxyData {
helpers: IExecuteFunctions['helpers']; helpers: IExecuteFunctions['helpers'];
} }
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']); export const REQUIRED_N8N_ITEM_KEYS = new Set([
'json',
'binary',
'pairedItem',
'error',
/**
* The `index` key was added accidentally to Function, FunctionItem, Gong,
* Execute Workflow, and ToolWorkflowV2, so we need to allow it temporarily.
* Once we stop using it in all nodes, we can stop allowing the `index` key.
*/
'index',
]);
export function getSandboxContext( export function getSandboxContext(
this: IExecuteFunctions | ISupplyDataFunctions, this: IExecuteFunctions | ISupplyDataFunctions,

View file

@ -40,6 +40,13 @@ describe('Code Node unit test', () => {
[{ json: { count: 42 } }], [{ json: { count: 42 } }],
[{ json: { count: 42 } }], [{ json: { count: 42 } }],
], ],
// temporarily allowed until refactored out
'should handle an index key': [
[{ json: { count: 42 }, index: 0 }],
[{ json: { count: 42 }, index: 0 }],
],
'should handle when returned data is not an array': [ 'should handle when returned data is not an array': [
{ json: { count: 42 } }, { json: { count: 42 } },
[{ json: { count: 42 } }], [{ json: { count: 42 } }],

View file

@ -19,7 +19,6 @@ import {
WAIT_INDEFINITELY, WAIT_INDEFINITELY,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { type CompletionPageConfig } from './interfaces';
import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils'; import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils';
@ -273,19 +272,19 @@ export class Form extends Node {
const method = context.getRequestObject().method; const method = context.getRequestObject().method;
if (operation === 'completion' && method === 'GET') { if (operation === 'completion' && method === 'GET') {
const staticData = context.getWorkflowStaticData('node'); const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const id = `${context.getExecutionId()}-${context.getNode().name}`; const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const config = staticData?.[id] as CompletionPageConfig; const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
delete staticData[id]; const options = context.getNodeParameter('options', {}) as { formTitle: string };
if (config.redirectUrl) { if (redirectUrl) {
res.send( res.send(
`<html><head><meta http-equiv="refresh" content="0; url=${config.redirectUrl}"></head></html>`, `<html><head><meta http-equiv="refresh" content="0; url=${redirectUrl}"></head></html>`,
); );
return { noWebhookResponse: true }; return { noWebhookResponse: true };
} }
let title = config.pageTitle; let title = options.formTitle;
if (!title) { if (!title) {
title = context.evaluateExpression( title = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formTitle }}`, `{{ $('${trigger?.name}').params.formTitle }}`,
@ -296,8 +295,8 @@ export class Form extends Node {
) as boolean; ) as boolean;
res.render('form-trigger-completion', { res.render('form-trigger-completion', {
title: config.completionTitle, title: completionTitle,
message: config.completionMessage, message: completionMessage,
formTitle: title, formTitle: title,
appendAttribution, appendAttribution,
}); });
@ -419,28 +418,7 @@ export class Form extends Node {
); );
} }
if (operation !== 'completion') { await context.putExecutionToWait(WAIT_INDEFINITELY);
await context.putExecutionToWait(WAIT_INDEFINITELY);
} else {
const staticData = context.getWorkflowStaticData('node');
const completionTitle = context.getNodeParameter('completionTitle', 0, '') as string;
const completionMessage = context.getNodeParameter('completionMessage', 0, '') as string;
const redirectUrl = context.getNodeParameter('redirectUrl', 0, '') as string;
const options = context.getNodeParameter('options', 0, {}) as { formTitle: string };
const id = `${context.getExecutionId()}-${context.getNode().name}`;
const config: CompletionPageConfig = {
completionTitle,
completionMessage,
redirectUrl,
pageTitle: options.formTitle,
};
staticData[id] = config;
const waitTill = new Date(WAIT_INDEFINITELY);
await context.putExecutionToWait(waitTill);
}
return [context.getInputData()]; return [context.getInputData()];
} }

View file

@ -32,11 +32,4 @@ export type FormTriggerData = {
buttonLabel?: string; buttonLabel?: string;
}; };
export type CompletionPageConfig = {
pageTitle?: string;
completionMessage?: string;
completionTitle?: string;
redirectUrl?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication'; export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';

View file

@ -172,7 +172,7 @@ describe('Form Node', () => {
]); ]);
}); });
it('should handle completion operation', async () => { it('should handle completion operation and render completion page', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request); mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => { mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion'; if (paramName === 'operation') return 'completion';
@ -181,6 +181,7 @@ describe('Form Node', () => {
if (paramName === 'respondWith') return 'text'; if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Test Title'; if (paramName === 'completionTitle') return 'Test Title';
if (paramName === 'completionMessage') return 'Test Message'; if (paramName === 'completionMessage') return 'Test Message';
if (paramName === 'redirectUrl') return '';
return {}; return {};
}); });
mockWebhookFunctions.getParentNodes.mockReturnValue([ mockWebhookFunctions.getParentNodes.mockReturnValue([
@ -202,16 +203,55 @@ describe('Form Node', () => {
); );
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName })); mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
mockWebhookFunctions.getExecutionId.mockReturnValue(testExecutionId); mockWebhookFunctions.getExecutionId.mockReturnValue(testExecutionId);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
[`${testExecutionId}-${formCompletionNodeName}`]: { redirectUrl: '' },
});
const result = await form.webhook(mockWebhookFunctions); const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true }); expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith( expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', {
'form-trigger-completion', appendAttribution: 'test',
expect.any(Object), formTitle: 'test',
message: 'Test Message',
title: 'Test Title',
});
});
it('should handle completion operation and redirect', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion';
if (paramName === 'useJson') return false;
if (paramName === 'jsonOutput') return '[]';
if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Test Title';
if (paramName === 'completionMessage') return 'Test Message';
if (paramName === 'redirectUrl') return 'https://n8n.io';
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
{
type: 'n8n-nodes-base.formTrigger',
name: 'Form Trigger',
typeVersion: 2.1,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
const mockResponseObject = {
render: jest.fn(),
redirect: jest.fn(),
send: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.send).toHaveBeenCalledWith(
'<html><head><meta http-equiv="refresh" content="0; url=https://n8n.io"></head></html>',
); );
}); });
}); });

View file

@ -66,7 +66,7 @@ function parseJsonExample(context: IWorkflowNodeContext): JSONSchema7 {
} }
export function getFieldEntries(context: IWorkflowNodeContext): FieldValueOption[] { export function getFieldEntries(context: IWorkflowNodeContext): FieldValueOption[] {
const inputSource = context.getNodeParameter(INPUT_SOURCE, 0); const inputSource = context.getNodeParameter(INPUT_SOURCE, 0, PASSTHROUGH);
let result: FieldValueOption[] | string = 'Internal Error: Invalid input source'; let result: FieldValueOption[] | string = 'Internal Error: Invalid input source';
try { try {
if (inputSource === WORKFLOW_INPUTS) { if (inputSource === WORKFLOW_INPUTS) {