mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 05:17: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>({
|
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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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' }),
|
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' }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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' });
|
||||||
|
|
|
@ -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('ipad') ||
|
|
||||||
userAgent.value.includes('iphone') ||
|
userAgent.value.includes('iphone') ||
|
||||||
|
userAgent.value.includes('ipad') ||
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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';
|
||||||
|
|
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 {
|
.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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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' } }],
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
<span :class="$style['filter-button-text']">
|
||||||
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
{{ 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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 } }],
|
||||||
|
|
|
@ -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()];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in a new issue