mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 21:07:28 -08:00
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:
commit
80cb4d51af
|
@ -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,
|
||||
}
|
||||
`;
|
|
@ -49,7 +49,11 @@ describe('createVectorStoreNode', () => {
|
|||
const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({
|
||||
sharedFields: [],
|
||||
insertFields: [],
|
||||
loadFields: [],
|
||||
loadFields: [
|
||||
{
|
||||
name: 'loadField',
|
||||
},
|
||||
],
|
||||
retrieveFields: [],
|
||||
updateFields: [],
|
||||
getVectorStoreClient: jest.fn().mockReturnValue(vectorStore),
|
||||
|
@ -82,6 +86,7 @@ describe('createVectorStoreNode', () => {
|
|||
const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped;
|
||||
|
||||
// ASSERT
|
||||
expect(nodeType.description).toMatchSnapshot();
|
||||
expect(wrappedVectorStore).toEqual(vectorStore);
|
||||
expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -80,10 +80,13 @@ export interface VectorStoreNodeConstructorArgs {
|
|||
) => Promise<VectorStore>;
|
||||
}
|
||||
|
||||
function transformDescriptionForOperationMode(fields: INodeProperties[], mode: NodeOperationMode) {
|
||||
function transformDescriptionForOperationMode(
|
||||
fields: INodeProperties[],
|
||||
mode: NodeOperationMode | NodeOperationMode[],
|
||||
) {
|
||||
return fields.map((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.updateFields ?? [], 'update'),
|
||||
],
|
||||
|
|
|
@ -28,6 +28,7 @@ describe('result validation', () => {
|
|||
['binary', {}],
|
||||
['pairedItem', {}],
|
||||
['error', {}],
|
||||
['index', {}], // temporarily allowed until refactored out
|
||||
])(
|
||||
'should not throw an error if the output item has %s key in addition to json',
|
||||
(key, value) => {
|
||||
|
|
|
@ -4,7 +4,19 @@ import type { INodeExecutionData } from 'n8n-workflow';
|
|||
import { ValidationError } from './errors/validation-error';
|
||||
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) {
|
||||
for (const key in item) {
|
||||
|
|
|
@ -87,7 +87,7 @@ export class EnterpriseCredentialsService {
|
|||
if (credential) {
|
||||
// Decrypt the data if we found the credential with the `credential:update`
|
||||
// scope.
|
||||
decryptedData = this.credentialsService.decrypt(credential);
|
||||
decryptedData = this.credentialsService.decrypt(credential, true);
|
||||
} else {
|
||||
// Otherwise try to find them with only the `credential:read` scope. In
|
||||
// that case we return them without the decrypted data.
|
||||
|
|
|
@ -542,7 +542,7 @@ export class CredentialsService {
|
|||
if (sharing) {
|
||||
// Decrypt the data if we found the credential with the `credential:update`
|
||||
// scope.
|
||||
decryptedData = this.decrypt(sharing.credentials);
|
||||
decryptedData = this.decrypt(sharing.credentials, true);
|
||||
} else {
|
||||
// Otherwise try to find them with only the `credential:read` scope. In
|
||||
// that case we return them without the decrypted data.
|
||||
|
|
|
@ -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
|
||||
export type MockedNodeItem = {
|
||||
name: string;
|
||||
name?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -16,6 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
|
|||
description: z.string().optional(),
|
||||
evaluationWorkflowId: 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();
|
||||
|
|
|
@ -121,13 +121,26 @@ export class TestDefinitionService {
|
|||
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) => {
|
||||
if (!existingNodeNames.has(node.name)) {
|
||||
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`);
|
||||
if (!existingNodeIds.has(node.id) || (node.name && !existingNodeNames.has(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
|
||||
|
|
|
@ -7,13 +7,24 @@ const wfUnderTestJson = JSON.parse(
|
|||
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(
|
||||
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
describe('createPinData', () => {
|
||||
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);
|
||||
|
||||
|
@ -25,7 +36,7 @@ describe('createPinData', () => {
|
|||
});
|
||||
|
||||
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);
|
||||
|
||||
|
@ -33,9 +44,17 @@ describe('createPinData', () => {
|
|||
});
|
||||
|
||||
test('should create pin data for all mocked nodes', () => {
|
||||
const mockedNodes = ['When clicking ‘Test workflow’', 'Edit Fields', 'Code'].map((name) => ({
|
||||
name,
|
||||
}));
|
||||
const mockedNodes = [
|
||||
{
|
||||
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);
|
||||
|
||||
|
@ -53,4 +72,33 @@ describe('createPinData', () => {
|
|||
|
||||
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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,12 @@ const wfUnderTestJson = JSON.parse(
|
|||
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(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
@ -60,6 +66,7 @@ const executionMocks = [
|
|||
status: 'success',
|
||||
executionData: {
|
||||
data: stringify(executionDataJson),
|
||||
workflowData: wfUnderTestJson,
|
||||
},
|
||||
}),
|
||||
mock<ExecutionEntity>({
|
||||
|
@ -68,6 +75,7 @@ const executionMocks = [
|
|||
status: 'success',
|
||||
executionData: {
|
||||
data: stringify(executionDataJson),
|
||||
workflowData: wfUnderTestRenamedNodesJson,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
@ -252,7 +260,7 @@ describe('TestRunnerService', () => {
|
|||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-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>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [{ name: 'When clicking ‘Test workflow’' }],
|
||||
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { parse } from 'flatted';
|
||||
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IRun,
|
||||
IRunData,
|
||||
IRunExecutionData,
|
||||
IWorkflowBase,
|
||||
IWorkflowExecutionDataProcess,
|
||||
} from 'n8n-workflow';
|
||||
import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
|
@ -97,6 +99,7 @@ export class TestRunnerService {
|
|||
private async runTestCase(
|
||||
workflow: WorkflowEntity,
|
||||
pastExecutionData: IRunExecutionData,
|
||||
pastExecutionWorkflowData: IWorkflowBase,
|
||||
mockedNodes: MockedNodeItem[],
|
||||
userId: string,
|
||||
abortSignal: AbortSignal,
|
||||
|
@ -107,7 +110,12 @@ export class TestRunnerService {
|
|||
}
|
||||
|
||||
// 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
|
||||
const data: IWorkflowExecutionDataProcess = {
|
||||
|
@ -269,7 +277,7 @@ export class TestRunnerService {
|
|||
const testCaseExecution = await this.runTestCase(
|
||||
workflow,
|
||||
executionData,
|
||||
test.mockedNodes,
|
||||
pastExecution.executionData.workflowData,test.mockedNodes,
|
||||
user.id,
|
||||
abortSignal,
|
||||
);
|
||||
|
|
|
@ -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 { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
|
@ -13,16 +14,33 @@ export function createPinData(
|
|||
workflow: WorkflowEntity,
|
||||
mockedNodes: MockedNodeItem[],
|
||||
executionData: IRunExecutionData,
|
||||
pastWorkflowData?: IWorkflowBase,
|
||||
) {
|
||||
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) {
|
||||
if (workflowNodeNames.has(mockedNode.name)) {
|
||||
const nodeData = executionData.resultData.runData[mockedNode.name];
|
||||
assert(mockedNode.id, 'Mocked node ID is missing');
|
||||
|
||||
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]) {
|
||||
pinData[mockedNode.name] = nodeData[0]?.data?.main?.[0];
|
||||
pinData[nodeName] = nodeData[0]?.data?.main?.[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
|
|||
import { In } from '@n8n/typeorm';
|
||||
|
||||
import config from '@/config';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { ProjectRole } from '@/databases/entities/project-relation';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
|
@ -555,6 +556,22 @@ describe('GET /credentials/:id', () => {
|
|||
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 () => {
|
||||
const [member1, member2] = await createManyUsers(2, {
|
||||
role: 'global:member',
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { Scope } from '@sentry/node';
|
|||
import { Credentials } from 'n8n-core';
|
||||
import { randomString } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
|
@ -1272,6 +1273,23 @@ describe('GET /credentials/:id', () => {
|
|||
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 () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), {
|
||||
user: member,
|
||||
|
|
|
@ -405,13 +405,14 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
|
|||
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||
mockedNodes: [
|
||||
{
|
||||
id: 'uuid-1234',
|
||||
name: 'Schedule Trigger',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
|
|
@ -34,7 +34,11 @@ const classes = computed(() => ({
|
|||
<slot name="footer" />
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -45,7 +49,7 @@ const classes = computed(() => ({
|
|||
border-radius: var(--border-radius-large);
|
||||
border: var(--border-base);
|
||||
background-color: var(--color-background-xlight);
|
||||
padding: var(--spacing-s);
|
||||
padding: var(--card--padding, var(--spacing-s));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
|
@ -101,5 +105,6 @@ const classes = computed(() => ({
|
|||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
width: var(--card--append--width, unset);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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()', () => {
|
||||
it('should return true for metaKey press on macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
|
||||
|
|
|
@ -12,12 +12,16 @@ export function useDeviceSupport() {
|
|||
!window.matchMedia('(any-pointer: fine)').matches,
|
||||
);
|
||||
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('iphone') ||
|
||||
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');
|
||||
|
||||
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
|
||||
|
@ -30,7 +34,10 @@ export function useDeviceSupport() {
|
|||
return {
|
||||
userAgent: userAgent.value,
|
||||
isTouchDevice: isTouchDevice.value,
|
||||
isAndroidOs: isAndroidOs.value,
|
||||
isIOs: isIOs.value,
|
||||
isMacOs: isMacOs.value,
|
||||
isMobileDevice: isMobileDevice.value,
|
||||
controlKeyCode: controlKeyCode.value,
|
||||
isCtrlKeyPressed,
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
@use './base.scss';
|
||||
@use './pagination.scss';
|
||||
@use './dialog.scss';
|
||||
@use './display.scss';
|
||||
// @use "./autocomplete.scss";
|
||||
@use './dropdown.scss';
|
||||
@use './dropdown-menu.scss';
|
||||
|
|
20
packages/design-system/src/css/mixins/_breakpoints.scss
Normal file
20
packages/design-system/src/css/mixins/_breakpoints.scss
Normal 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`.";
|
||||
}
|
||||
}
|
6
packages/design-system/src/css/mixins/index.scss
Normal file
6
packages/design-system/src/css/mixins/index.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
@forward 'breakpoints';
|
||||
@forward 'button';
|
||||
@forward 'config';
|
||||
@forward 'function';
|
||||
@forward 'mixins';
|
||||
@forward 'utils';
|
|
@ -192,6 +192,8 @@ watch(defaultLocale, (newLocale) => {
|
|||
.header {
|
||||
grid-area: header;
|
||||
z-index: var(--z-index-app-header);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
|
|
@ -162,6 +162,7 @@ function moveResource() {
|
|||
<template #append>
|
||||
<div :class="$style.cardActions" @click.stop>
|
||||
<ProjectCardBadge
|
||||
:class="$style.cardBadge"
|
||||
:resource="data"
|
||||
:resource-type="ResourceType.Credential"
|
||||
:resource-type-label="resourceTypeLabel"
|
||||
|
@ -180,9 +181,10 @@ function moveResource() {
|
|||
|
||||
<style lang="scss" module>
|
||||
.cardLink {
|
||||
--card--padding: 0 0 0 var(--spacing-s);
|
||||
|
||||
transition: box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 var(--spacing-s);
|
||||
align-items: stretch;
|
||||
|
||||
&:hover {
|
||||
|
@ -215,4 +217,22 @@ function moveResource() {
|
|||
padding: 0 var(--spacing-s) 0 0;
|
||||
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>
|
||||
|
|
|
@ -1106,7 +1106,7 @@ function resetCredentialData(): void {
|
|||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.container" data-test-id="credential-edit-dialog">
|
||||
<div :class="$style.sidebar">
|
||||
<div v-if="!isEditingManagedCredential" :class="$style.sidebar">
|
||||
<n8n-menu
|
||||
mode="tabs"
|
||||
:items="sidebarItems"
|
||||
|
|
|
@ -3,6 +3,8 @@ import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants';
|
||||
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import type { ICredentialsResponse } from '@/Interface';
|
||||
|
||||
vi.mock('@/permissions', () => ({
|
||||
getResourcePermissions: vi.fn(() => ({
|
||||
|
@ -23,6 +25,10 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
|
|||
},
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
sharing: true,
|
||||
externalSecrets: false,
|
||||
},
|
||||
templates: {
|
||||
host: '',
|
||||
},
|
||||
|
@ -67,4 +73,54 @@ describe('CredentialEdit', () => {
|
|||
});
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -220,7 +220,7 @@ function hideGithubButton() {
|
|||
@update:model-value="onTabSelected"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showGitHubButton" class="github-button">
|
||||
<div v-if="showGitHubButton" class="github-button hidden-sm-and-down">
|
||||
<div class="github-button-container">
|
||||
<GithubButton
|
||||
href="https://github.com/n8n-io/n8n"
|
||||
|
@ -264,6 +264,7 @@ function hideGithubButton() {
|
|||
font-size: 0.9em;
|
||||
font-weight: 400;
|
||||
padding: var(--spacing-xs) var(--spacing-m);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.github-button {
|
||||
|
|
|
@ -800,6 +800,8 @@ $--header-spacing: 20px;
|
|||
.name {
|
||||
color: $custom-font-dark;
|
||||
font-size: 15px;
|
||||
display: block;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.activator {
|
||||
|
@ -807,7 +809,6 @@ $--header-spacing: 20px;
|
|||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: $--text-line-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> span {
|
||||
|
@ -845,24 +846,24 @@ $--header-spacing: 20px;
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { DRAG_EVENT_DATA_KEY } from '@/constants';
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import N8nIconButton from 'n8n-design-system/components/N8nIconButton/IconButton.vue';
|
||||
|
||||
export interface Props {
|
||||
active?: boolean;
|
||||
|
@ -145,6 +146,14 @@ onBeforeUnmount(() => {
|
|||
[$style.active]: showScrim,
|
||||
}"
|
||||
/>
|
||||
<N8nIconButton
|
||||
v-if="active"
|
||||
:class="$style.close"
|
||||
type="secondary"
|
||||
icon="times"
|
||||
aria-label="Close Node Creator"
|
||||
@click="emit('closeNodeCreator')"
|
||||
/>
|
||||
<SlideTransition>
|
||||
<div
|
||||
v-if="active"
|
||||
|
@ -168,13 +177,14 @@ onBeforeUnmount(() => {
|
|||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.nodeCreator {
|
||||
--node-creator-width: #{$node-creator-width};
|
||||
--node-icon-color: var(--color-text-base);
|
||||
position: fixed;
|
||||
top: $header-height;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-index-node-creator);
|
||||
width: $node-creator-width;
|
||||
width: var(--node-creator-width);
|
||||
color: $node-creator-text-color;
|
||||
}
|
||||
|
||||
|
@ -194,4 +204,24 @@ onBeforeUnmount(() => {
|
|||
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>
|
||||
|
|
|
@ -260,7 +260,7 @@ function onBackButton() {
|
|||
height: 100%;
|
||||
background-color: $node-creator-background-color;
|
||||
--color-background-node-icon-badge: var(--color-background-xlight);
|
||||
width: 385px;
|
||||
width: var(--node-creator-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
@ -303,6 +303,7 @@ function onBackButton() {
|
|||
line-height: 24px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-l);
|
||||
margin: 0;
|
||||
|
||||
.hasBg & {
|
||||
font-size: var(--font-size-s-m);
|
||||
|
|
|
@ -126,7 +126,7 @@ const badgeTooltip = computed(() => {
|
|||
</script>
|
||||
<template>
|
||||
<N8nTooltip :disabled="!badgeTooltip" placement="top">
|
||||
<div class="mr-xs">
|
||||
<div :class="$style.wrapper" v-bind="$attrs">
|
||||
<N8nBadge
|
||||
v-if="badgeText"
|
||||
:class="$style.badge"
|
||||
|
@ -153,6 +153,10 @@ const badgeTooltip = computed(() => {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: var(--spacing-4xs) var(--spacing-2xs);
|
||||
background-color: var(--color-background-xlight);
|
||||
|
|
|
@ -106,10 +106,10 @@ const onSelect = (action: string) => {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<div :class="[$style.projectHeader]">
|
||||
<div :class="[$style.projectDetails]">
|
||||
<div :class="$style.projectHeader">
|
||||
<div :class="$style.projectDetails">
|
||||
<ProjectIcon :icon="headerIcon" :border-less="true" size="medium" />
|
||||
<div>
|
||||
<div :class="$style.headerActions">
|
||||
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
||||
<N8nText color="text-light">
|
||||
<slot name="subtitle">
|
||||
|
@ -147,7 +147,8 @@ const onSelect = (action: string) => {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.projectHeader {
|
||||
.projectHeader,
|
||||
.projectDescription {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
@ -163,4 +164,16 @@ const onSelect = (action: string) => {
|
|||
.actions {
|
||||
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>
|
||||
|
|
|
@ -122,7 +122,7 @@ const showAddFirstProject = computed(
|
|||
},
|
||||
]"
|
||||
:disabled="isCreatingProject"
|
||||
type="tertiary"
|
||||
type="secondary"
|
||||
icon="plus"
|
||||
data-test-id="add-first-project-button"
|
||||
@click="globalEntityCreation.createProject"
|
||||
|
@ -187,7 +187,6 @@ const showAddFirstProject = computed(
|
|||
}
|
||||
|
||||
.addFirstProjectBtn {
|
||||
border: 1px solid var(--color-background-dark);
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--spacing-3xs);
|
||||
margin: 0 var(--spacing-m) var(--spacing-m);
|
||||
|
|
|
@ -187,6 +187,28 @@ describe('RunData', () => {
|
|||
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 () => {
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [{ json: { name: 'Test' } }],
|
||||
|
|
|
@ -1277,10 +1277,16 @@ defineExpose({ enterEditMode });
|
|||
<template>
|
||||
<div :class="['run-data', $style.container]" @mouseover="activatePane">
|
||||
<N8nCallout
|
||||
v-if="pinnedData.hasData.value && !editMode.enabled && !isProductionExecutionPreview"
|
||||
v-if="
|
||||
!isPaneTypeInput &&
|
||||
pinnedData.hasData.value &&
|
||||
!editMode.enabled &&
|
||||
!isProductionExecutionPreview
|
||||
"
|
||||
theme="secondary"
|
||||
icon="thumbtack"
|
||||
:class="$style.pinnedDataCallout"
|
||||
data-test-id="ndv-pinned-data-callout"
|
||||
>
|
||||
{{ i18n.baseText('runData.pindata.thisDataIsPinned') }}
|
||||
<span v-if="!isReadOnlyRoute && !readOnlyEnv" class="ml-4xs">
|
||||
|
|
|
@ -268,6 +268,7 @@ function moveResource() {
|
|||
<template #append>
|
||||
<div :class="$style.cardActions" @click.stop>
|
||||
<ProjectCardBadge
|
||||
:class="$style.cardBadge"
|
||||
:resource="data"
|
||||
:resource-type="ResourceType.Workflow"
|
||||
:resource-type-label="resourceTypeLabel"
|
||||
|
@ -330,4 +331,22 @@ function moveResource() {
|
|||
padding: 0 var(--spacing-s) 0 0;
|
||||
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>
|
||||
|
|
|
@ -7,105 +7,7 @@ exports[`InputPanel > should render 1`] = `
|
|||
data-test-id="ndv-input-panel"
|
||||
data-v-2e5cd75c=""
|
||||
>
|
||||
<div
|
||||
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
|
||||
class="header"
|
||||
|
|
|
@ -93,7 +93,7 @@ const props = withDefaults(
|
|||
},
|
||||
);
|
||||
|
||||
const { controlKeyCode } = useDeviceSupport();
|
||||
const { isMobileDevice, controlKeyCode } = useDeviceSupport();
|
||||
|
||||
const vueFlow = useVueFlow({ id: props.id, deleteKeyCode: null });
|
||||
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
|
||||
*/
|
||||
const panningKeyCode = ref<string[]>([' ', controlKeyCode]);
|
||||
const panningMouseButton = ref<number[]>([1]);
|
||||
const selectionKeyCode = ref<true | null>(true);
|
||||
|
||||
const panningKeyCode = ref<string[] | true>(isMobileDevice ? true : [' ', controlKeyCode]);
|
||||
const panningMouseButton = ref<number[] | true>(isMobileDevice ? true : [1]);
|
||||
const selectionKeyCode = ref<string | true | null>(isMobileDevice ? 'Shift' : true);
|
||||
|
||||
onKeyDown(panningKeyCode.value, () => {
|
||||
selectionKeyCode.value = null;
|
||||
|
|
|
@ -483,6 +483,10 @@ const goToUpgrade = () => {
|
|||
width: 100%;
|
||||
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
||||
max-width: var(--content-container-width);
|
||||
|
||||
@include mixins.breakpoint('xs-only') {
|
||||
padding: var(--spacing-xs) var(--spacing-xs) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.execList {
|
||||
|
|
|
@ -129,4 +129,14 @@ onBeforeRouteLeave(async (to, _, next) => {
|
|||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -265,6 +265,7 @@ const goToUpgrade = () => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heading {
|
||||
|
@ -314,9 +315,10 @@ const goToUpgrade = () => {
|
|||
bottom: 0;
|
||||
margin-left: calc(-1 * var(--spacing-l));
|
||||
border-top: var(--border-base);
|
||||
width: 100%;
|
||||
|
||||
& > div {
|
||||
width: 309px;
|
||||
width: 100%;
|
||||
background-color: var(--color-background-light);
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
|
|
@ -99,10 +99,16 @@ onBeforeMount(async () => {
|
|||
:class="$style['filter-button']"
|
||||
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 }}
|
||||
</n8n-badge>
|
||||
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
||||
<span :class="$style['filter-button-text']">
|
||||
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
||||
</span>
|
||||
</n8n-button>
|
||||
</template>
|
||||
<div :class="$style['filters-dropdown']" data-test-id="resources-list-filters-dropdown">
|
||||
|
@ -139,6 +145,25 @@ onBeforeMount(async () => {
|
|||
.filter-button {
|
||||
height: 40px;
|
||||
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 {
|
||||
|
|
|
@ -17,6 +17,10 @@
|
|||
box-sizing: border-box;
|
||||
align-content: start;
|
||||
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
padding: var(--spacing-s) var(--spacing-s) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
|
|
|
@ -475,6 +475,7 @@ onMounted(async () => {
|
|||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
|
@ -483,10 +484,24 @@ onMounted(async () => {
|
|||
grid-auto-columns: max-content;
|
||||
gap: var(--spacing-2xs);
|
||||
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 {
|
||||
max-width: 240px;
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.listWrapper {
|
||||
|
@ -497,6 +512,10 @@ onMounted(async () => {
|
|||
|
||||
.sort-and-filter {
|
||||
white-space: nowrap;
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.datatable {
|
||||
|
|
|
@ -2557,7 +2557,7 @@
|
|||
"projects.menu.overview": "Overview",
|
||||
"projects.menu.title": "Projects",
|
||||
"projects.menu.personal": "Personal",
|
||||
"projects.menu.addFirstProject": "Add first project",
|
||||
"projects.menu.addFirstProject": "Add project",
|
||||
"projects.settings": "Project settings",
|
||||
"projects.settings.newProjectName": "My project",
|
||||
"projects.settings.iconPicker.button.tooltip": "Choose project icon",
|
||||
|
|
|
@ -56,6 +56,10 @@
|
|||
fill: var(--color-foreground-dark);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('xs-only') {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -100,3 +104,20 @@
|
|||
.vue-flow__edge-label.selected {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1772,11 +1772,13 @@ onBeforeUnmount(() => {
|
|||
align-items: center;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: var(--spacing-l);
|
||||
bottom: var(--spacing-s);
|
||||
width: auto;
|
||||
|
||||
@media (max-width: $breakpoint-2xs) {
|
||||
bottom: 150px;
|
||||
@include mixins.breakpoint('sm-only') {
|
||||
left: auto;
|
||||
right: var(--spacing-s);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
button {
|
||||
|
@ -1788,6 +1790,17 @@ onBeforeUnmount(() => {
|
|||
&:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('xs-only') {
|
||||
text-indent: -10000px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
|
||||
span {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -85,7 +85,11 @@ export default mergeConfig(
|
|||
css: {
|
||||
preprocessorOptions: {
|
||||
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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -22,7 +22,19 @@ export interface SandboxContext extends IWorkflowDataProxyData {
|
|||
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(
|
||||
this: IExecuteFunctions | ISupplyDataFunctions,
|
||||
|
|
|
@ -40,6 +40,13 @@ describe('Code Node unit test', () => {
|
|||
[{ 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': [
|
||||
{ json: { count: 42 } },
|
||||
[{ json: { count: 42 } }],
|
||||
|
|
|
@ -19,7 +19,6 @@ import {
|
|||
WAIT_INDEFINITELY,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { type CompletionPageConfig } from './interfaces';
|
||||
import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
|
||||
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils';
|
||||
|
||||
|
@ -273,19 +272,19 @@ export class Form extends Node {
|
|||
const method = context.getRequestObject().method;
|
||||
|
||||
if (operation === 'completion' && method === 'GET') {
|
||||
const staticData = context.getWorkflowStaticData('node');
|
||||
const id = `${context.getExecutionId()}-${context.getNode().name}`;
|
||||
const config = staticData?.[id] as CompletionPageConfig;
|
||||
delete staticData[id];
|
||||
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
|
||||
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
|
||||
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
|
||||
const options = context.getNodeParameter('options', {}) as { formTitle: string };
|
||||
|
||||
if (config.redirectUrl) {
|
||||
if (redirectUrl) {
|
||||
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 };
|
||||
}
|
||||
|
||||
let title = config.pageTitle;
|
||||
let title = options.formTitle;
|
||||
if (!title) {
|
||||
title = context.evaluateExpression(
|
||||
`{{ $('${trigger?.name}').params.formTitle }}`,
|
||||
|
@ -296,8 +295,8 @@ export class Form extends Node {
|
|||
) as boolean;
|
||||
|
||||
res.render('form-trigger-completion', {
|
||||
title: config.completionTitle,
|
||||
message: config.completionMessage,
|
||||
title: completionTitle,
|
||||
message: completionMessage,
|
||||
formTitle: title,
|
||||
appendAttribution,
|
||||
});
|
||||
|
@ -419,28 +418,7 @@ export class Form extends Node {
|
|||
);
|
||||
}
|
||||
|
||||
if (operation !== 'completion') {
|
||||
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);
|
||||
}
|
||||
await context.putExecutionToWait(WAIT_INDEFINITELY);
|
||||
|
||||
return [context.getInputData()];
|
||||
}
|
||||
|
|
|
@ -32,11 +32,4 @@ export type FormTriggerData = {
|
|||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
export type CompletionPageConfig = {
|
||||
pageTitle?: string;
|
||||
completionMessage?: string;
|
||||
completionTitle?: string;
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';
|
||||
|
|
|
@ -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.getNodeParameter.mockImplementation((paramName) => {
|
||||
if (paramName === 'operation') return 'completion';
|
||||
|
@ -181,6 +181,7 @@ describe('Form Node', () => {
|
|||
if (paramName === 'respondWith') return 'text';
|
||||
if (paramName === 'completionTitle') return 'Test Title';
|
||||
if (paramName === 'completionMessage') return 'Test Message';
|
||||
if (paramName === 'redirectUrl') return '';
|
||||
return {};
|
||||
});
|
||||
mockWebhookFunctions.getParentNodes.mockReturnValue([
|
||||
|
@ -202,16 +203,55 @@ describe('Form Node', () => {
|
|||
);
|
||||
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
|
||||
mockWebhookFunctions.getExecutionId.mockReturnValue(testExecutionId);
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
|
||||
[`${testExecutionId}-${formCompletionNodeName}`]: { redirectUrl: '' },
|
||||
});
|
||||
|
||||
const result = await form.webhook(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
expect(mockResponseObject.render).toHaveBeenCalledWith(
|
||||
'form-trigger-completion',
|
||||
expect.any(Object),
|
||||
expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', {
|
||||
appendAttribution: 'test',
|
||||
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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -66,7 +66,7 @@ function parseJsonExample(context: IWorkflowNodeContext): JSONSchema7 {
|
|||
}
|
||||
|
||||
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';
|
||||
try {
|
||||
if (inputSource === WORKFLOW_INPUTS) {
|
||||
|
|
Loading…
Reference in a new issue