diff --git a/.github/workflows/benchmark-nightly.yml b/.github/workflows/benchmark-nightly.yml index 2de86abaf740e..d557a0c9ee6e3 100644 --- a/.github/workflows/benchmark-nightly.yml +++ b/.github/workflows/benchmark-nightly.yml @@ -24,6 +24,9 @@ env: ARM_SUBSCRIPTION_ID: ${{ secrets.BENCHMARK_ARM_SUBSCRIPTION_ID }} ARM_TENANT_ID: ${{ secrets.BENCHMARK_ARM_TENANT_ID }} K6_API_TOKEN: ${{ secrets.K6_API_TOKEN }} + N8N_TAG: ${{ inputs.n8n_tag || 'nightly' }} + N8N_BENCHMARK_TAG: ${{ inputs.benchmark_tag || 'latest' }} + DEBUG: ${{ inputs.debug == 'true' && '--debug' || '' }} permissions: id-token: write @@ -62,12 +65,23 @@ jobs: run: pnpm destroy-cloud-env working-directory: packages/@n8n/benchmark - - name: Run the benchmark with debug logging - if: github.event.inputs.debug == 'true' - run: pnpm benchmark-in-cloud --n8nTag ${{ inputs.n8n_tag || 'nightly' }} --benchmarkTag ${{ inputs.benchmark_tag || 'latest' }} --debug + - name: Provision the environment + run: pnpm provision-cloud-env ${{ env.DEBUG }} working-directory: packages/@n8n/benchmark - name: Run the benchmark - if: github.event.inputs.debug != 'true' - run: pnpm benchmark-in-cloud --n8nTag ${{ inputs.n8n_tag || 'nightly' }} --benchmarkTag ${{ inputs.benchmark_tag || 'latest' }} + run: pnpm benchmark-in-cloud --n8nTag ${{ env.N8N_TAG }} --benchmarkTag ${{ env.N8N_BENCHMARK_TAG }} ${{ env.DEBUG }} + working-directory: packages/@n8n/benchmark + + # We need to login again because the access token expires + - name: Azure login + uses: azure/login@v2.1.1 + with: + client-id: ${{ env.ARM_CLIENT_ID }} + tenant-id: ${{ env.ARM_TENANT_ID }} + subscription-id: ${{ env.ARM_SUBSCRIPTION_ID }} + + - name: Destroy the environment + if: always() + run: pnpm destroy-cloud-env ${{ env.DEBUG }} working-directory: packages/@n8n/benchmark diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index 2d9affef5dbc5..d7a8fb8a5f231 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -13,6 +13,7 @@ "benchmark": "zx scripts/run.mjs", "benchmark-in-cloud": "pnpm benchmark --env cloud", "benchmark-locally": "pnpm benchmark --env local", + "provision-cloud-env": "zx scripts/provisionCloudEnv.mjs", "destroy-cloud-env": "zx scripts/destroyCloudEnv.mjs", "watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\"" }, diff --git a/packages/@n8n/benchmark/scripts/clients/terraformClient.mjs b/packages/@n8n/benchmark/scripts/clients/terraformClient.mjs index b156998f92d7a..522e35b6e98e4 100644 --- a/packages/@n8n/benchmark/scripts/clients/terraformClient.mjs +++ b/packages/@n8n/benchmark/scripts/clients/terraformClient.mjs @@ -17,6 +17,16 @@ export class TerraformClient { }); } + /** + * Provisions the environment + */ + async provisionEnvironment() { + console.log('Provisioning cloud environment...'); + + await this.$$`terraform init`; + await this.$$`terraform apply -input=false -auto-approve`; + } + /** * @typedef {Object} BenchmarkEnv * @property {string} vmName @@ -26,12 +36,7 @@ export class TerraformClient { * * @returns {Promise<BenchmarkEnv>} */ - async provisionEnvironment() { - console.log('Provisioning cloud environment...'); - - await this.$$`terraform init`; - await this.$$`terraform apply -input=false -auto-approve`; - + async getTerraformOutputs() { const privateKeyName = await this.extractPrivateKey(); return { @@ -42,12 +47,11 @@ export class TerraformClient { }; } - async destroyEnvironment() { - if (!fs.existsSync(paths.terraformStateFile)) { - console.log('No cloud environment to destroy. Skipping...'); - return; - } + hasTerraformState() { + return fs.existsSync(paths.terraformStateFile); + } + async destroyEnvironment() { console.log('Destroying cloud environment...'); await this.$$`terraform destroy -input=false -auto-approve`; diff --git a/packages/@n8n/benchmark/scripts/destroyCloudEnv.mjs b/packages/@n8n/benchmark/scripts/destroyCloudEnv.mjs index 1ffc852aeaab8..0e203efca353a 100644 --- a/packages/@n8n/benchmark/scripts/destroyCloudEnv.mjs +++ b/packages/@n8n/benchmark/scripts/destroyCloudEnv.mjs @@ -1,52 +1,43 @@ #!/usr/bin/env zx /** - * Script that deletes all resources created by the benchmark environment - * and that are older than 2 hours. + * Script that deletes all resources created by the benchmark environment. * - * Even tho the environment is provisioned using terraform, the terraform - * state is not persisted. Hence we can't use terraform to delete the resources. - * We could store the state to a storage account, but then we wouldn't be able - * to spin up new envs on-demand. Hence this design. - * - * Usage: - * zx scripts/deleteCloudEnv.mjs + * This scripts tries to delete resources created by Terraform. If Terraform + * state file is not found, it will try to delete resources using Azure CLI. + * The terraform state is not persisted, so we want to support both cases. */ // @ts-check -import { $ } from 'zx'; +import { $, minimist } from 'zx'; +import { TerraformClient } from './clients/terraformClient.mjs'; -const EXPIRE_TIME_IN_H = 2; -const EXPIRE_TIME_IN_MS = EXPIRE_TIME_IN_H * 60 * 60 * 1000; const RESOURCE_GROUP_NAME = 'n8n-benchmarking'; +const args = minimist(process.argv.slice(3), { + boolean: ['debug'], +}); + +const isVerbose = !!args.debug; + async function main() { + const terraformClient = new TerraformClient({ isVerbose }); + + if (terraformClient.hasTerraformState()) { + await terraformClient.destroyEnvironment(); + } else { + await destroyUsingAz(); + } +} + +async function destroyUsingAz() { const resourcesResult = await $`az resource list --resource-group ${RESOURCE_GROUP_NAME} --query "[?tags.Id == 'N8nBenchmark'].{id:id, createdAt:tags.CreatedAt}" -o json`; const resources = JSON.parse(resourcesResult.stdout); - const now = Date.now(); - - const resourcesToDelete = resources - .filter((resource) => { - if (resource.createdAt === undefined) { - return true; - } - - const createdAt = new Date(resource.createdAt); - const resourceExpiredAt = createdAt.getTime() + EXPIRE_TIME_IN_MS; - - return now > resourceExpiredAt; - }) - .map((resource) => resource.id); + const resourcesToDelete = resources.map((resource) => resource.id); if (resourcesToDelete.length === 0) { - if (resources.length === 0) { - console.log('No resources found in the resource group.'); - } else { - console.log( - `Found ${resources.length} resources in the resource group, but none are older than ${EXPIRE_TIME_IN_H} hours.`, - ); - } + console.log('No resources found in the resource group.'); return; } @@ -87,4 +78,9 @@ async function deleteById(id) { } } -main(); +main().catch((error) => { + console.error('An error occurred destroying cloud env:'); + console.error(error); + + process.exit(1); +}); diff --git a/packages/@n8n/benchmark/scripts/provisionCloudEnv.mjs b/packages/@n8n/benchmark/scripts/provisionCloudEnv.mjs new file mode 100644 index 0000000000000..5f10e7c60c72f --- /dev/null +++ b/packages/@n8n/benchmark/scripts/provisionCloudEnv.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env zx +/** + * Provisions the cloud benchmark environment + * + * NOTE: Must be run in the root of the package. + */ +// @ts-check +import { which, minimist } from 'zx'; +import { TerraformClient } from './clients/terraformClient.mjs'; + +const args = minimist(process.argv.slice(3), { + boolean: ['debug'], +}); + +const isVerbose = !!args.debug; + +export async function provision() { + await ensureDependencies(); + + const terraformClient = new TerraformClient({ + isVerbose, + }); + + await terraformClient.provisionEnvironment(); +} + +async function ensureDependencies() { + await which('terraform'); +} + +provision().catch((error) => { + console.error('An error occurred while provisioning cloud env:'); + console.error(error); + + process.exit(1); +}); diff --git a/packages/@n8n/benchmark/scripts/run.mjs b/packages/@n8n/benchmark/scripts/run.mjs index ece2e942d6f14..a276eee5fcc50 100755 --- a/packages/@n8n/benchmark/scripts/run.mjs +++ b/packages/@n8n/benchmark/scripts/run.mjs @@ -1,12 +1,9 @@ #!/usr/bin/env zx /** * Script to run benchmarks either on the cloud benchmark environment or locally. + * The cloud environment needs to be provisioned using Terraform before running the benchmarks. * * NOTE: Must be run in the root of the package. - * - * Usage: - * zx scripts/run.mjs - * */ // @ts-check import fs from 'fs'; diff --git a/packages/@n8n/benchmark/scripts/runInCloud.mjs b/packages/@n8n/benchmark/scripts/runInCloud.mjs index 8730807a6ed6c..1c1b191ca9a0d 100755 --- a/packages/@n8n/benchmark/scripts/runInCloud.mjs +++ b/packages/@n8n/benchmark/scripts/runInCloud.mjs @@ -39,16 +39,9 @@ export async function runInCloud(config) { isVerbose: config.isVerbose, }); - try { - const benchmarkEnv = await terraformClient.provisionEnvironment(); + const benchmarkEnv = await terraformClient.getTerraformOutputs(); - await runBenchmarksOnVm(config, benchmarkEnv); - } catch (error) { - console.error('An error occurred while running the benchmarks:'); - console.error(error); - } finally { - await terraformClient.destroyEnvironment(); - } + await runBenchmarksOnVm(config, benchmarkEnv); } async function ensureDependencies() { @@ -117,7 +110,15 @@ async function runBenchmarkForN8nSetup({ config, sshClient, scriptsDir, n8nSetup } async function ensureVmIsReachable(sshClient) { - await sshClient.ssh('echo "VM is reachable"'); + try { + await sshClient.ssh('echo "VM is reachable"'); + } catch (error) { + console.error(`VM is not reachable: ${error.message}`); + console.error( + `Did you provision the cloud environment first with 'pnpm provision-cloud-env'? You can also run the benchmarks locally with 'pnpm run benchmark-locally'.`, + ); + process.exit(1); + } } /** diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 05b4a5cadc6f7..bdac2048b2d9c 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -1,10 +1,10 @@ +import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { EventNamesAiNodesType, IDataObject, IExecuteFunctions, IWebhookFunctions, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseOutputParser } from '@langchain/core/output_parsers'; import type { BaseMessage } from '@langchain/core/messages'; diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 5b2cc284e93dc..eb6c55fa5dd7e 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -13,11 +13,12 @@ import { } from 'n8n-core'; import type { KnownNodesAndCredentials, + INodeTypeBaseDescription, INodeTypeDescription, INodeTypeData, ICredentialTypeData, } from 'n8n-workflow'; -import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { CUSTOM_API_CALL_KEY, @@ -38,8 +39,11 @@ interface LoadedNodesAndCredentials { export class LoadNodesAndCredentials { private known: KnownNodesAndCredentials = { nodes: {}, credentials: {} }; + // This contains the actually loaded objects, and their source paths loaded: LoadedNodesAndCredentials = { nodes: {}, credentials: {} }; + // For nodes, this only contains the descriptions, loaded from either the + // actual file, or the lazy loaded json types: Types = { nodes: [], credentials: [] }; loaders: Record<string, DirectoryLoader> = {}; @@ -260,6 +264,34 @@ export class LoadNodesAndCredentials { return loader; } + /** + * This creates all AI Agent tools by duplicating the node descriptions for + * all nodes that are marked as `usableAsTool`. It basically modifies the + * description. The actual wrapping happens in the langchain code for getting + * the connected tools. + */ + createAiTools() { + const usableNodes: Array<INodeTypeBaseDescription | INodeTypeDescription> = + this.types.nodes.filter((nodetype) => nodetype.usableAsTool === true); + + for (const usableNode of usableNodes) { + const description: INodeTypeBaseDescription | INodeTypeDescription = + structuredClone(usableNode); + const wrapped = NodeHelpers.convertNodeToAiTool({ description }).description; + + this.types.nodes.push(wrapped); + this.known.nodes[wrapped.name] = structuredClone(this.known.nodes[usableNode.name]); + + const credentialNames = Object.entries(this.known.credentials) + .filter(([_, credential]) => credential?.supportedNodes?.includes(usableNode.name)) + .map(([credentialName]) => credentialName); + + credentialNames.forEach((name) => + this.known.credentials[name]?.supportedNodes?.push(wrapped.name), + ); + } + } + async postProcessLoaders() { this.known = { nodes: {}, credentials: {} }; this.loaded = { nodes: {}, credentials: {} }; @@ -307,6 +339,8 @@ export class LoadNodesAndCredentials { } } + this.createAiTools(); + this.injectCustomApiCallOptions(); for (const postProcessor of this.postProcessors) { diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index dc8ea2860cda9..550b836a16c66 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -43,7 +43,15 @@ export class NodeTypes implements INodeTypes { } getByNameAndVersion(nodeType: string, version?: number): INodeType { - return NodeHelpers.getVersionedNodeType(this.getNode(nodeType).type, version); + const versionedNodeType = NodeHelpers.getVersionedNodeType( + this.getNode(nodeType).type, + version, + ); + if (versionedNodeType.description.usableAsTool) { + return NodeHelpers.convertNodeToAiTool(versionedNodeType); + } + + return versionedNodeType; } /* Some nodeTypes need to get special parameters applied like the polling nodes the polling times */ @@ -66,8 +74,9 @@ export class NodeTypes implements INodeTypes { if (type in knownNodes) { const { className, sourcePath } = knownNodes[type]; - const loaded: INodeType = loadClassInIsolation(sourcePath, className); - NodeHelpers.applySpecialNodeParameters(loaded); + const loaded: INodeType | IVersionedNodeType = loadClassInIsolation(sourcePath, className); + if (NodeHelpers.isINodeType(loaded)) NodeHelpers.applySpecialNodeParameters(loaded); + loadedNodes[type] = { sourcePath, type: loaded }; return loadedNodes[type]; } diff --git a/packages/core/package.json b/packages/core/package.json index 04412c4c8ce14..f0de12c0f49bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,7 @@ "file-type": "16.5.4", "form-data": "catalog:", "lodash": "catalog:", + "@langchain/core": "0.2.18", "luxon": "catalog:", "mime-types": "2.1.35", "n8n-workflow": "workspace:*", @@ -54,6 +55,7 @@ "ssh2": "1.15.0", "typedi": "catalog:", "uuid": "catalog:", - "xml2js": "catalog:" + "xml2js": "catalog:", + "zod": "catalog:" } } diff --git a/packages/core/src/CreateNodeAsTool.ts b/packages/core/src/CreateNodeAsTool.ts new file mode 100644 index 0000000000000..21e1b6352ad1a --- /dev/null +++ b/packages/core/src/CreateNodeAsTool.ts @@ -0,0 +1,296 @@ +/** + * @module NodeAsTool + * @description This module converts n8n nodes into LangChain tools by analyzing node parameters, + * identifying placeholders, and generating a Zod schema. It then creates a DynamicStructuredTool + * that can be used in LangChain workflows. + * + * General approach: + * 1. Recursively traverse node parameters to find placeholders, including in nested structures + * 2. Generate a Zod schema based on these placeholders, preserving the nested structure + * 3. Create a DynamicStructuredTool with the schema and a function that executes the n8n node + * + * Example: + * - Node parameters: + * { + * "inputText": "{{ '__PLACEHOLDER: Enter main text to process' }}", + * "options": { + * "language": "{{ '__PLACEHOLDER: Specify language' }}", + * "advanced": { + * "maxLength": "{{ '__PLACEHOLDER: Enter maximum length' }}" + * } + * } + * } + * + * - Generated Zod schema: + * z.object({ + * "inputText": z.string().describe("Enter main text to process"), + * "options__language": z.string().describe("Specify language"), + * "options__advanced__maxLength": z.string().describe("Enter maximum length") + * }).required() + * + * - Resulting tool can be called with: + * { + * "inputText": "Hello, world!", + * "options__language": "en", + * "options__advanced__maxLength": "100" + * } + * + * Note: Nested properties are flattened with double underscores in the schema, + * but the tool reconstructs the original nested structure when executing the node. + */ + +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { + NodeConnectionType, + type IExecuteFunctions, + type INodeParameters, + type INodeType, +} from 'n8n-workflow'; +import { z } from 'zod'; + +/** Represents a nested object structure */ +type NestedObject = { [key: string]: unknown }; + +/** + * Encodes a dot-notated key to a format safe for use as an object key. + * @param {string} key - The dot-notated key to encode. + * @returns {string} The encoded key. + */ +function encodeDotNotation(key: string): string { + // Replace dots with double underscores, then handle special case for '__value' for complicated params + return key.replace(/\./g, '__').replace('__value', ''); +} + +/** + * Decodes an encoded key back to its original dot-notated form. + * @param {string} key - The encoded key to decode. + * @returns {string} The decoded, dot-notated key. + */ +function decodeDotNotation(key: string): string { + // Simply replace double underscores with dots + return key.replace(/__/g, '.'); +} + +/** + * Recursively traverses an object to find placeholder values. + * @param {NestedObject} obj - The object to traverse. + * @param {string[]} path - The current path in the object. + * @param {Map<string, string>} results - Map to store found placeholders. + * @returns {Map<string, string>} Updated map of placeholders. + */ +function traverseObject( + obj: NestedObject, + path: string[] = [], + results: Map<string, string> = new Map(), +): Map<string, string> { + for (const [key, value] of Object.entries(obj)) { + const currentPath = [...path, key]; + const fullPath = currentPath.join('.'); + + if (typeof value === 'string' && value.startsWith("{{ '__PLACEHOLDER")) { + // Store placeholder values with their full path + results.set(encodeDotNotation(fullPath), value); + } else if (Array.isArray(value)) { + // Recursively traverse arrays + // eslint-disable-next-line @typescript-eslint/no-use-before-define + traverseArray(value, currentPath, results); + } else if (typeof value === 'object' && value !== null) { + // Recursively traverse nested objects, but only if they're not empty + if (Object.keys(value).length > 0) { + traverseObject(value as NestedObject, currentPath, results); + } + } + } + + return results; +} + +/** + * Recursively traverses an array to find placeholder values. + * @param {unknown[]} arr - The array to traverse. + * @param {string[]} path - The current path in the array. + * @param {Map<string, string>} results - Map to store found placeholders. + */ +function traverseArray(arr: unknown[], path: string[], results: Map<string, string>): void { + arr.forEach((item, index) => { + const currentPath = [...path, index.toString()]; + const fullPath = currentPath.join('.'); + + if (typeof item === 'string' && item.startsWith("{{ '__PLACEHOLDER")) { + // Store placeholder values with their full path + results.set(encodeDotNotation(fullPath), item); + } else if (Array.isArray(item)) { + // Recursively traverse nested arrays + traverseArray(item, currentPath, results); + } else if (typeof item === 'object' && item !== null) { + // Recursively traverse nested objects + traverseObject(item as NestedObject, currentPath, results); + } + }); +} + +/** + * Builds a nested object structure from matching keys and their values. + * @param {string} baseKey - The base key to start building from. + * @param {string[]} matchingKeys - Array of matching keys. + * @param {Record<string, string>} values - Object containing values for the keys. + * @returns {Record<string, unknown>} The built nested object structure. + */ +function buildStructureFromMatches( + baseKey: string, + matchingKeys: string[], + values: Record<string, string>, +): Record<string, unknown> { + const result = {}; + + for (const matchingKey of matchingKeys) { + const decodedKey = decodeDotNotation(matchingKey); + // Extract the part of the key after the base key + const remainingPath = decodedKey + .slice(baseKey.length) + .split('.') + .filter((k) => k !== ''); + let current: Record<string, unknown> = result; + + // Build the nested structure + for (let i = 0; i < remainingPath.length - 1; i++) { + if (!(remainingPath[i] in current)) { + current[remainingPath[i]] = {}; + } + current = current[remainingPath[i]] as Record<string, unknown>; + } + + // Set the value at the deepest level + const lastKey = remainingPath[remainingPath.length - 1]; + current[lastKey ?? matchingKey] = values[matchingKey]; + } + + // If no nested structure was created, return the direct value + return Object.keys(result).length === 0 ? values[encodeDotNotation(baseKey)] : result; +} + +/** + * Extracts the description from a placeholder string. + * @param {string} value - The placeholder string. + * @returns {string} The extracted description or a default message. + */ +function extractPlaceholderDescription(value: string): string { + const match = value.match(/{{ '__PLACEHOLDER:\s*(.+?)\s*' }}/); + return match ? match[1] : 'No description provided'; +} + +/** + * Creates a DynamicStructuredTool from an n8n node. + * @param {INodeType} node - The n8n node to convert. + * @param {IExecuteFunctions} ctx - The execution context. + * @param {INodeParameters} nodeParameters - The node parameters. + * @returns {DynamicStructuredTool} The created tool. + */ +export function createNodeAsTool( + node: INodeType, + ctx: IExecuteFunctions, + nodeParameters: INodeParameters, +): DynamicStructuredTool { + // Find all placeholder values in the node parameters + const placeholderValues = traverseObject(nodeParameters); + + // Generate Zod schema from placeholder values + const schemaObj: { [key: string]: z.ZodString } = {}; + for (const [key, value] of placeholderValues.entries()) { + const description = extractPlaceholderDescription(value); + schemaObj[key] = z.string().describe(description); + } + const schema = z.object(schemaObj).required(); + + // Get the tool description from node parameters or use the default + const toolDescription = ctx.getNodeParameter( + 'toolDescription', + 0, + node.description.description, + ) as string; + type GetNodeParameterMethod = IExecuteFunctions['getNodeParameter']; + + const tool = new DynamicStructuredTool({ + name: node.description.name, + description: toolDescription ? toolDescription : node.description.description, + schema, + func: async (functionArgs: z.infer<typeof schema>) => { + // Create a proxy for ctx to soft-override parameters with values from the LLM + const ctxProxy = new Proxy(ctx, { + get(target: IExecuteFunctions, prop: string | symbol, receiver: unknown) { + if (prop === 'getNodeParameter') { + // Override getNodeParameter method + // eslint-disable-next-line @typescript-eslint/unbound-method + return new Proxy(target.getNodeParameter, { + apply( + targetMethod: GetNodeParameterMethod, + thisArg: unknown, + argumentsList: Parameters<GetNodeParameterMethod>, + ): ReturnType<GetNodeParameterMethod> { + const [key] = argumentsList; + if (typeof key !== 'string') { + // If key is not a string, use the original method + return Reflect.apply(targetMethod, thisArg, argumentsList); + } + + const encodedKey = encodeDotNotation(key); + // Check if the full key or any more specific key is a placeholder + const matchingKeys = Array.from(placeholderValues.keys()).filter((k) => + k.startsWith(encodedKey), + ); + + if (matchingKeys.length > 0) { + // If there are matching keys, build the structure using args + const res = buildStructureFromMatches(encodedKey, matchingKeys, functionArgs); + // Return either the specific value or the entire built structure + return res?.[decodeDotNotation(key)] ?? res; + } + + // If no placeholder is found, use the original function + return Reflect.apply(targetMethod, thisArg, argumentsList); + }, + }); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Reflect.get(target, prop, receiver); + }, + }); + + // Add input data to the context + ctxProxy.addInputData(NodeConnectionType.AiTool, [[{ json: functionArgs }]]); + + // Execute the node with the proxied context + const result = await node.execute?.bind(ctxProxy)(); + + // Process and map the results + const mappedResults = result?.[0]?.flatMap((item) => item.json); + + // Add output data to the context + ctxProxy.addOutputData(NodeConnectionType.AiTool, 0, [ + [{ json: { response: mappedResults } }], + ]); + + // Return the stringified results + return JSON.stringify(mappedResults); + }, + }); + + return tool; +} + +/** + * Asynchronously creates a DynamicStructuredTool from an n8n node. + * @param {IExecuteFunctions} ctx - The execution context. + * @param {INodeType} node - The n8n node to convert. + * @param {INodeParameters} nodeParameters - The node parameters. + * @returns {Promise<{response: DynamicStructuredTool}>} A promise that resolves to an object containing the created tool. + */ +export function getNodeAsTool( + ctx: IExecuteFunctions, + node: INodeType, + nodeParameters: INodeParameters, +) { + return { + response: createNodeAsTool(node, ctx, nodeParameters), + }; +} diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index 77c107ef732a6..717edd5359de8 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -40,14 +40,20 @@ export type Types = { export abstract class DirectoryLoader { isLazyLoaded = false; + // Another way of keeping track of the names and versions of a node. This + // seems to only be used by the installedPackages repository loadedNodes: INodeTypeNameVersion[] = []; + // Stores the loaded descriptions and sourcepaths nodeTypes: INodeTypeData = {}; credentialTypes: ICredentialTypeData = {}; + // Stores the location and classnames of the nodes and credentials that are + // loaded; used to actually load the files in lazy-loading scenario. known: KnownNodesAndCredentials = { nodes: {}, credentials: {} }; + // Stores the different versions with their individual descriptions types: Types = { nodes: [], credentials: [] }; protected nodesByCredential: Record<string, string[]> = {}; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index c4c00bd3c8013..3f7be2a7050df 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -159,6 +159,7 @@ import { InstanceSettings } from './InstanceSettings'; import { ScheduledTaskManager } from './ScheduledTaskManager'; import { SSHClientsManager } from './SSHClientsManager'; import { binaryToBuffer } from './BinaryData/utils'; +import { getNodeAsTool } from './CreateNodeAsTool'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -2780,12 +2781,6 @@ async function getInputConnectionData( connectedNode.typeVersion, ); - if (!nodeType.supplyData) { - throw new ApplicationError('Node does not have a `supplyData` method defined', { - extra: { nodeName: connectedNode.name }, - }); - } - const context = Object.assign({}, this); context.getNodeParameter = ( @@ -2853,6 +2848,18 @@ async function getInputConnectionData( } }; + if (!nodeType.supplyData) { + if (nodeType.description.outputs.includes(NodeConnectionType.AiTool)) { + nodeType.supplyData = async function (this: IExecuteFunctions) { + return getNodeAsTool(this, nodeType, this.getNode().parameters); + }; + } else { + throw new ApplicationError('Node does not have a `supplyData` method defined', { + extra: { nodeName: connectedNode.name }, + }); + } + } + try { const response = await nodeType.supplyData.call(context, itemIndex); if (response.closeFunction) { diff --git a/packages/core/test/CreateNodeAsTool.test.ts b/packages/core/test/CreateNodeAsTool.test.ts new file mode 100644 index 0000000000000..c4509e08be239 --- /dev/null +++ b/packages/core/test/CreateNodeAsTool.test.ts @@ -0,0 +1,92 @@ +import { createNodeAsTool } from '@/CreateNodeAsTool'; +import type { IExecuteFunctions, INodeParameters, INodeType } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; +import { z } from 'zod'; + +jest.mock('@langchain/core/tools', () => ({ + DynamicStructuredTool: jest.fn().mockImplementation((config) => ({ + name: config.name, + description: config.description, + schema: config.schema, + func: config.func, + })), +})); + +describe('createNodeAsTool', () => { + let mockCtx: IExecuteFunctions; + let mockNode: INodeType; + let mockNodeParameters: INodeParameters; + + beforeEach(() => { + mockCtx = { + getNodeParameter: jest.fn(), + addInputData: jest.fn(), + addOutputData: jest.fn(), + } as unknown as IExecuteFunctions; + + mockNode = { + description: { + name: 'TestNode', + description: 'Test node description', + }, + execute: jest.fn().mockResolvedValue([[{ json: { result: 'test' } }]]), + } as unknown as INodeType; + + mockNodeParameters = { + param1: "{{ '__PLACEHOLDER: Test parameter' }}", + param2: 'static value', + nestedParam: { + subParam: "{{ '__PLACEHOLDER: Nested parameter' }}", + }, + }; + jest.clearAllMocks(); + }); + + it('should create a DynamicStructuredTool with correct properties', () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool).toBeDefined(); + expect(tool.name).toBe('TestNode'); + expect(tool.description).toBe('Test node description'); + expect(tool.schema).toBeDefined(); + }); + + it('should use toolDescription if provided', () => { + const customDescription = 'Custom tool description'; + (mockCtx.getNodeParameter as jest.Mock).mockReturnValue(customDescription); + + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool.description).toBe(customDescription); + }); + + it('should create a schema based on placeholder values in nodeParameters', () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool.schema).toBeDefined(); + expect(tool.schema.shape).toHaveProperty('param1'); + expect(tool.schema.shape).toHaveProperty('nestedParam__subParam'); + expect(tool.schema.shape).not.toHaveProperty('param2'); + }); + + it('should handle nested parameters correctly', () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + expect(tool.schema.shape.nestedParam__subParam).toBeInstanceOf(z.ZodString); + }); + + it('should create a function that wraps the node execution', async () => { + const tool = createNodeAsTool(mockNode, mockCtx, mockNodeParameters); + + const result = await tool.func({ param1: 'test value', nestedParam__subParam: 'nested value' }); + + expect(mockCtx.addInputData).toHaveBeenCalledWith(NodeConnectionType.AiTool, [ + [{ json: { param1: 'test value', nestedParam__subParam: 'nested value' } }], + ]); + expect(mockNode.execute).toHaveBeenCalled(); + expect(mockCtx.addOutputData).toHaveBeenCalledWith(NodeConnectionType.AiTool, 0, [ + [{ json: { response: [{ result: 'test' }] } }], + ]); + expect(result).toBe(JSON.stringify([{ result: 'test' }])); + }); +}); diff --git a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts index 7385100141e85..85c306cdf6c5f 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts +++ b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts @@ -229,6 +229,7 @@ function resourceCategories(nodeTypeDescription: INodeTypeDescription): ActionTy export function useActionsGenerator() { function generateNodeActions(node: INodeTypeDescription | undefined) { if (!node) return []; + if (node.codex?.subcategories?.AI?.includes('Tools')) return []; return [...triggersCategory(node), ...operationsCategory(node), ...resourceCategories(node)]; } function filterActions(actions: ActionTypeDescription[]) { diff --git a/packages/editor-ui/src/components/PersonalizationModal.test.ts b/packages/editor-ui/src/components/PersonalizationModal.test.ts new file mode 100644 index 0000000000000..160beefccd617 --- /dev/null +++ b/packages/editor-ui/src/components/PersonalizationModal.test.ts @@ -0,0 +1,157 @@ +import userEvent from '@testing-library/user-event'; +import { createComponentRenderer } from '@/__tests__/render'; +import { getDropdownItems, mockedStore } from '@/__tests__/utils'; +import { createUser } from '@/__tests__/data/users'; +import { useSettingsStore } from '@/stores/settings.store'; +import PersonalizationModal from '@/components/PersonalizationModal.vue'; +import { useUsersStore } from '@/stores/users.store'; +import { createTestingPinia } from '@pinia/testing'; +import { + COMPANY_TYPE_KEY, + EMAIL_KEY, + COMPANY_INDUSTRY_EXTENDED_KEY, + OTHER_COMPANY_INDUSTRY_EXTENDED_KEY, + MARKETING_AUTOMATION_GOAL_KEY, + OTHER_MARKETING_AUTOMATION_GOAL_KEY, + ROLE_KEY, + ROLE_OTHER_KEY, + DEVOPS_AUTOMATION_GOAL_OTHER_KEY, + DEVOPS_AUTOMATION_GOAL_KEY, +} from '@/constants'; + +const renderModal = createComponentRenderer(PersonalizationModal, { + global: { + stubs: { + Modal: { + template: ` + <div> + <slot name="header" /> + <slot name="title" /> + <slot name="content" /> + <slot name="footer" /> + </div> + `, + }, + }, + }, +}); + +describe('PersonalizationModal', () => { + it('mounts', () => { + const { getByTitle } = renderModal({ pinia: createTestingPinia() }); + expect(getByTitle('Customize n8n to you')).toBeInTheDocument(); + }); + + it('shows user input when needed for desktop deployment', () => { + const pinia = createTestingPinia(); + const usersStore = mockedStore(useUsersStore); + usersStore.currentUser = createUser({ firstName: undefined }); + + const settingsStore = mockedStore(useSettingsStore); + settingsStore.isDesktopDeployment = true; + + const { getByTestId } = renderModal({ pinia }); + expect(getByTestId(EMAIL_KEY)).toBeInTheDocument(); + }); + + describe('Company field', () => { + it('allows completion of company related fields', async () => { + const { getByTestId } = renderModal({ pinia: createTestingPinia() }); + + const companyTypeSelect = getByTestId(COMPANY_TYPE_KEY); + + const otherTypeOfCompanyOption = [...(await getDropdownItems(companyTypeSelect))].find( + (node) => node.textContent === 'Other', + ) as Element; + + await userEvent.click(otherTypeOfCompanyOption); + + const industrySelect = getByTestId(COMPANY_INDUSTRY_EXTENDED_KEY); + expect(industrySelect).toBeInTheDocument(); + + const otherIndustryOption = [...(await getDropdownItems(industrySelect))].find( + (node) => node.textContent === 'Other (please specify)', + ) as Element; + + await userEvent.click(otherIndustryOption); + + expect(getByTestId(OTHER_COMPANY_INDUSTRY_EXTENDED_KEY)).toBeInTheDocument(); + }); + + it('shows only company and source select when not used for work', async () => { + const { getByTestId, baseElement } = renderModal({ pinia: createTestingPinia() }); + + const companyTypeSelect = getByTestId(COMPANY_TYPE_KEY); + + const nonWorkOption = [...(await getDropdownItems(companyTypeSelect))].find( + (node) => node.textContent === "I'm not using n8n for work", + ) as Element; + + await userEvent.click(nonWorkOption); + + expect(baseElement.querySelectorAll('input').length).toBe(2); + }); + }); + + it('allows completion of role related fields', async () => { + const { getByTestId, queryByTestId } = renderModal({ pinia: createTestingPinia() }); + + const roleSelect = getByTestId(ROLE_KEY); + const roleItems = [...(await getDropdownItems(roleSelect))]; + + const devOps = roleItems.find((node) => node.textContent === 'Devops') as Element; + const engineering = roleItems.find((node) => node.textContent === 'Engineering') as Element; + const it = roleItems.find((node) => node.textContent === 'IT') as Element; + const other = roleItems.find( + (node) => node.textContent === 'Other (please specify)', + ) as Element; + + await userEvent.click(devOps); + const automationGoalSelect = getByTestId(DEVOPS_AUTOMATION_GOAL_KEY); + expect(automationGoalSelect).toBeInTheDocument(); + + await userEvent.click(engineering); + expect(automationGoalSelect).toBeInTheDocument(); + + await userEvent.click(it); + expect(automationGoalSelect).toBeInTheDocument(); + + const otherGoalsItem = [...(await getDropdownItems(automationGoalSelect))].find( + (node) => node.textContent === 'Other', + ) as Element; + + await userEvent.click(otherGoalsItem); + expect(getByTestId(DEVOPS_AUTOMATION_GOAL_OTHER_KEY)).toBeInTheDocument(); + + await userEvent.click(other); + expect(queryByTestId(DEVOPS_AUTOMATION_GOAL_KEY)).not.toBeInTheDocument(); + expect(getByTestId(ROLE_OTHER_KEY)).toBeInTheDocument(); + }); + + it('allows completion of marketing and sales related fields', async () => { + const { getByTestId } = renderModal({ pinia: createTestingPinia() }); + + const companyTypeSelect = getByTestId(COMPANY_TYPE_KEY); + + const anyWorkOption = [...(await getDropdownItems(companyTypeSelect))].find( + (node) => node.textContent !== "I'm not using n8n for work", + ) as Element; + + await userEvent.click(anyWorkOption); + + const roleSelect = getByTestId(ROLE_KEY); + const salesAndMarketingOption = [...(await getDropdownItems(roleSelect))].find( + (node) => node.textContent === 'Sales and Marketing', + ) as Element; + + await userEvent.click(salesAndMarketingOption); + + const salesAndMarketingSelect = getByTestId(MARKETING_AUTOMATION_GOAL_KEY); + const otherItem = [...(await getDropdownItems(salesAndMarketingSelect))].find( + (node) => node.textContent === 'Other', + ) as Element; + + await userEvent.click(otherItem); + expect(getByTestId(OTHER_MARKETING_AUTOMATION_GOAL_KEY)).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index 13e360c3c1981..1eab5e4b20816 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -1,6 +1,5 @@ -<script lang="ts"> -import { defineComponent } from 'vue'; -import { mapStores } from 'pinia'; +<script lang="ts" setup> +import { computed, ref } from 'vue'; import { COMPANY_SIZE_100_499, COMPANY_SIZE_1000_OR_MORE, @@ -86,617 +85,513 @@ import { import { useToast } from '@/composables/useToast'; import Modal from '@/components/Modal.vue'; import type { IFormInputs, IPersonalizationLatestVersion } from '@/Interface'; -import { useUIStore } from '@/stores/ui.store'; import { useSettingsStore } from '@/stores/settings.store'; import { useRootStore } from '@/stores/root.store'; import { useUsersStore } from '@/stores/users.store'; import { createEventBus, createFormEventBus } from 'n8n-design-system/utils'; import { usePostHog } from '@/stores/posthog.store'; import { useExternalHooks } from '@/composables/useExternalHooks'; -import { useUsageStore } from '@/stores/usage.store'; -import { useMessage } from '@/composables/useMessage'; +import { useI18n } from '@/composables/useI18n'; +import { useRoute, useRouter } from 'vue-router'; const SURVEY_VERSION = 'v4'; -export default defineComponent({ - name: 'PersonalizationModal', - components: { Modal }, - props: { - teleported: { - type: Boolean, - default: true, +const externalHooks = useExternalHooks(); +const modalBus = createEventBus(); +const formBus = createFormEventBus(); +const { showError } = useToast(); +const i18n = useI18n(); +const rootStore = useRootStore(); +const settingsStore = useSettingsStore(); +const usersStore = useUsersStore(); +const posthogStore = usePostHog(); +const route = useRoute(); +const router = useRouter(); + +const formValues = ref<Record<string, string>>({}); +const isSaving = ref(false); + +const survey = computed<IFormInputs>(() => [ + { + name: EMAIL_KEY, + properties: { + label: i18n.baseText('personalizationModal.yourEmailAddress'), + type: 'text', + placeholder: i18n.baseText('personalizationModal.email'), }, + shouldDisplay: () => settingsStore.isDesktopDeployment && !usersStore.currentUser?.firstName, }, - setup() { - const externalHooks = useExternalHooks(); + { + name: COMPANY_TYPE_KEY, + properties: { + label: i18n.baseText('personalizationModal.whatBestDescribesYourCompany'), + type: 'select', + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + label: i18n.baseText('personalizationModal.saas'), + value: SAAS_COMPANY_TYPE, + }, + { + label: i18n.baseText('personalizationModal.eCommerce'), + value: ECOMMERCE_COMPANY_TYPE, + }, - return { - externalHooks, - ...useToast(), - ...useMessage(), - }; + { + label: i18n.baseText('personalizationModal.digitalAgencyOrConsultant'), + value: DIGITAL_AGENCY_COMPANY_TYPE, + }, + { + label: i18n.baseText('personalizationModal.systemsIntegrator'), + value: SYSTEMS_INTEGRATOR_COMPANY_TYPE, + }, + { + value: EDUCATION_TYPE, + label: i18n.baseText('personalizationModal.education'), + }, + { + label: i18n.baseText('personalizationModal.other'), + value: OTHER_COMPANY_TYPE, + }, + { + label: i18n.baseText('personalizationModal.imNotUsingN8nForWork'), + value: PERSONAL_COMPANY_TYPE, + }, + ], + }, }, - data() { - return { - formValues: {} as Record<string, string>, - isSaving: false, - PERSONALIZATION_MODAL_KEY, - otherWorkAreaFieldVisible: false, - otherCompanyIndustryFieldVisible: false, - showAllIndustryQuestions: true, - registerForEnterpriseTrial: false, - modalBus: createEventBus(), - formBus: createFormEventBus(), - domainBlocklist: [] as string[], - }; + { + name: COMPANY_INDUSTRY_EXTENDED_KEY, + properties: { + type: 'multi-select', + label: i18n.baseText('personalizationModal.whichIndustriesIsYourCompanyIn'), + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + value: FINANCE_INSURANCE_INDUSTRY, + label: i18n.baseText('personalizationModal.financeOrInsurance'), + }, + { + value: GOVERNMENT_INDUSTRY, + label: i18n.baseText('personalizationModal.government'), + }, + { + value: HEALTHCARE_INDUSTRY, + label: i18n.baseText('personalizationModal.healthcare'), + }, + { + value: IT_INDUSTRY, + label: i18n.baseText('personalizationModal.it'), + }, + { + value: LEGAL_INDUSTRY, + label: i18n.baseText('personalizationModal.legal'), + }, + { + value: MSP_INDUSTRY, + label: i18n.baseText('personalizationModal.managedServiceProvider'), + }, + { + value: MARKETING_INDUSTRY, + label: i18n.baseText('personalizationModal.marketing'), + }, + { + value: MEDIA_INDUSTRY, + label: i18n.baseText('personalizationModal.media'), + }, + { + value: MANUFACTURING_INDUSTRY, + label: i18n.baseText('personalizationModal.manufacturing'), + }, + { + value: PHYSICAL_RETAIL_OR_SERVICES, + label: i18n.baseText('personalizationModal.physicalRetailOrServices'), + }, + { + value: REAL_ESTATE_OR_CONSTRUCTION, + label: i18n.baseText('personalizationModal.realEstateOrConstruction'), + }, + { + value: SECURITY_INDUSTRY, + label: i18n.baseText('personalizationModal.security'), + }, + { + value: TELECOMS_INDUSTRY, + label: i18n.baseText('personalizationModal.telecoms'), + }, + { + value: OTHER_INDUSTRY_OPTION, + label: i18n.baseText('personalizationModal.otherPleaseSpecify'), + }, + ], + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + return companyType === OTHER_COMPANY_TYPE; + }, }, - computed: { - ...mapStores( - useRootStore, - useSettingsStore, - useUIStore, - useUsersStore, - useUsageStore, - usePostHog, - ), - currentUser() { - return this.usersStore.currentUser; + { + name: OTHER_COMPANY_INDUSTRY_EXTENDED_KEY, + properties: { + placeholder: i18n.baseText('personalizationModal.specifyYourCompanysIndustry'), }, - canRegisterForEnterpriseTrial() { - if ( - this.settingsStore.isCloudDeployment || - this.domainBlocklist.length === 0 || - !this.currentUser?.email - ) { - return false; - } - - const isSizeEligible = [COMPANY_SIZE_500_999, COMPANY_SIZE_1000_OR_MORE].includes( - this.formValues[COMPANY_SIZE_KEY], + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + const companyIndustry = (values as IPersonalizationLatestVersion)[ + COMPANY_INDUSTRY_EXTENDED_KEY + ]; + return ( + companyType === OTHER_COMPANY_TYPE && + !!companyIndustry && + companyIndustry.includes(OTHER_INDUSTRY_OPTION) ); - - const emailParts = this.currentUser.email.split('@'); - const emailDomain = emailParts[emailParts.length - 1]; - const isEmailEligible = !this.domainBlocklist.find( - (blocklistedDomain) => emailDomain === blocklistedDomain, + }, + }, + { + name: ROLE_KEY, + properties: { + type: 'select', + label: i18n.baseText('personalizationModal.whichRoleBestDescribesYou'), + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + value: ROLE_BUSINESS_OWNER, + label: i18n.baseText('personalizationModal.businessOwner'), + }, + { + value: ROLE_CUSTOMER_SUPPORT, + label: i18n.baseText('personalizationModal.customerSupport'), + }, + { + value: ROLE_DATA_SCIENCE, + label: i18n.baseText('personalizationModal.dataScience'), + }, + { + value: ROLE_DEVOPS, + label: i18n.baseText('personalizationModal.devops'), + }, + { + value: ROLE_IT, + label: i18n.baseText('personalizationModal.it'), + }, + { + value: ROLE_ENGINEERING, + label: i18n.baseText('personalizationModal.engineering'), + }, + { + value: ROLE_SALES_AND_MARKETING, + label: i18n.baseText('personalizationModal.salesAndMarketing'), + }, + { + value: ROLE_SECURITY, + label: i18n.baseText('personalizationModal.security'), + }, + { + value: ROLE_OTHER, + label: i18n.baseText('personalizationModal.otherPleaseSpecify'), + }, + ], + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + return companyType !== PERSONAL_COMPANY_TYPE; + }, + }, + { + name: ROLE_OTHER_KEY, + properties: { + placeholder: i18n.baseText('personalizationModal.specifyYourRole'), + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + const role = (values as IPersonalizationLatestVersion)[ROLE_KEY]; + return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_OTHER; + }, + }, + { + name: DEVOPS_AUTOMATION_GOAL_KEY, + properties: { + type: 'multi-select', + label: i18n.baseText('personalizationModal.whatAreYouLookingToAutomate'), + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + value: DEVOPS_AUTOMATION_CI_CD_GOAL, + label: i18n.baseText('personalizationModal.cicd'), + }, + { + value: DEVOPS_AUTOMATION_CLOUD_INFRASTRUCTURE_ORCHESTRATION_GOAL, + label: i18n.baseText('personalizationModal.cloudInfrastructureOrchestration'), + }, + { + value: DEVOPS_AUTOMATION_DATA_SYNCING_GOAL, + label: i18n.baseText('personalizationModal.dataSynching'), + }, + { + value: DEVOPS_INCIDENT_RESPONSE_GOAL, + label: i18n.baseText('personalizationModal.incidentResponse'), + }, + { + value: DEVOPS_MONITORING_AND_ALERTING_GOAL, + label: i18n.baseText('personalizationModal.monitoringAndAlerting'), + }, + { + value: DEVOPS_REPORTING_GOAL, + label: i18n.baseText('personalizationModal.reporting'), + }, + { + value: DEVOPS_TICKETING_SYSTEMS_INTEGRATIONS_GOAL, + label: i18n.baseText('personalizationModal.ticketingSystemsIntegrations'), + }, + { + value: OTHER_AUTOMATION_GOAL, + label: i18n.baseText('personalizationModal.other'), + }, + ], + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string; + return ( + companyType !== PERSONAL_COMPANY_TYPE && + [ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role) ); - - return isSizeEligible && isEmailEligible; }, - survey() { - const survey: IFormInputs = [ - { - name: EMAIL_KEY, - properties: { - label: this.$locale.baseText('personalizationModal.yourEmailAddress'), - type: 'text', - placeholder: this.$locale.baseText('personalizationModal.email'), - }, - shouldDisplay: () => - this.settingsStore.isDesktopDeployment && !this.usersStore.currentUser?.firstName, - }, - { - name: COMPANY_TYPE_KEY, - properties: { - label: this.$locale.baseText('personalizationModal.whatBestDescribesYourCompany'), - type: 'select', - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - label: this.$locale.baseText('personalizationModal.saas'), - value: SAAS_COMPANY_TYPE, - }, - { - label: this.$locale.baseText('personalizationModal.eCommerce'), - value: ECOMMERCE_COMPANY_TYPE, - }, - - { - label: this.$locale.baseText('personalizationModal.digitalAgencyOrConsultant'), - value: DIGITAL_AGENCY_COMPANY_TYPE, - }, - { - label: this.$locale.baseText('personalizationModal.systemsIntegrator'), - value: SYSTEMS_INTEGRATOR_COMPANY_TYPE, - }, - { - value: EDUCATION_TYPE, - label: this.$locale.baseText('personalizationModal.education'), - }, - { - label: this.$locale.baseText('personalizationModal.other'), - value: OTHER_COMPANY_TYPE, - }, - { - label: this.$locale.baseText('personalizationModal.imNotUsingN8nForWork'), - value: PERSONAL_COMPANY_TYPE, - }, - ], - }, - }, - { - name: COMPANY_INDUSTRY_EXTENDED_KEY, - properties: { - type: 'multi-select', - label: this.$locale.baseText('personalizationModal.whichIndustriesIsYourCompanyIn'), - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - value: FINANCE_INSURANCE_INDUSTRY, - label: this.$locale.baseText('personalizationModal.financeOrInsurance'), - }, - { - value: GOVERNMENT_INDUSTRY, - label: this.$locale.baseText('personalizationModal.government'), - }, - { - value: HEALTHCARE_INDUSTRY, - label: this.$locale.baseText('personalizationModal.healthcare'), - }, - { - value: IT_INDUSTRY, - label: this.$locale.baseText('personalizationModal.it'), - }, - { - value: LEGAL_INDUSTRY, - label: this.$locale.baseText('personalizationModal.legal'), - }, - { - value: MSP_INDUSTRY, - label: this.$locale.baseText('personalizationModal.managedServiceProvider'), - }, - { - value: MARKETING_INDUSTRY, - label: this.$locale.baseText('personalizationModal.marketing'), - }, - { - value: MEDIA_INDUSTRY, - label: this.$locale.baseText('personalizationModal.media'), - }, - { - value: MANUFACTURING_INDUSTRY, - label: this.$locale.baseText('personalizationModal.manufacturing'), - }, - { - value: PHYSICAL_RETAIL_OR_SERVICES, - label: this.$locale.baseText('personalizationModal.physicalRetailOrServices'), - }, - { - value: REAL_ESTATE_OR_CONSTRUCTION, - label: this.$locale.baseText('personalizationModal.realEstateOrConstruction'), - }, - { - value: SECURITY_INDUSTRY, - label: this.$locale.baseText('personalizationModal.security'), - }, - { - value: TELECOMS_INDUSTRY, - label: this.$locale.baseText('personalizationModal.telecoms'), - }, - { - value: OTHER_INDUSTRY_OPTION, - label: this.$locale.baseText('personalizationModal.otherPleaseSpecify'), - }, - ], - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - return companyType === OTHER_COMPANY_TYPE; - }, - }, - { - name: OTHER_COMPANY_INDUSTRY_EXTENDED_KEY, - properties: { - placeholder: this.$locale.baseText('personalizationModal.specifyYourCompanysIndustry'), - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - const companyIndustry = (values as IPersonalizationLatestVersion)[ - COMPANY_INDUSTRY_EXTENDED_KEY - ]; - return ( - companyType === OTHER_COMPANY_TYPE && - !!companyIndustry && - companyIndustry.includes(OTHER_INDUSTRY_OPTION) - ); - }, - }, - { - name: ROLE_KEY, - properties: { - type: 'select', - label: this.$locale.baseText('personalizationModal.whichRoleBestDescribesYou'), - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - value: ROLE_BUSINESS_OWNER, - label: this.$locale.baseText('personalizationModal.businessOwner'), - }, - { - value: ROLE_CUSTOMER_SUPPORT, - label: this.$locale.baseText('personalizationModal.customerSupport'), - }, - { - value: ROLE_DATA_SCIENCE, - label: this.$locale.baseText('personalizationModal.dataScience'), - }, - { - value: ROLE_DEVOPS, - label: this.$locale.baseText('personalizationModal.devops'), - }, - { - value: ROLE_IT, - label: this.$locale.baseText('personalizationModal.it'), - }, - { - value: ROLE_ENGINEERING, - label: this.$locale.baseText('personalizationModal.engineering'), - }, - { - value: ROLE_SALES_AND_MARKETING, - label: this.$locale.baseText('personalizationModal.salesAndMarketing'), - }, - { - value: ROLE_SECURITY, - label: this.$locale.baseText('personalizationModal.security'), - }, - { - value: ROLE_OTHER, - label: this.$locale.baseText('personalizationModal.otherPleaseSpecify'), - }, - ], - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - return companyType !== PERSONAL_COMPANY_TYPE; - }, - }, - { - name: ROLE_OTHER_KEY, - properties: { - placeholder: this.$locale.baseText('personalizationModal.specifyYourRole'), - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - const role = (values as IPersonalizationLatestVersion)[ROLE_KEY]; - return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_OTHER; - }, - }, - { - name: DEVOPS_AUTOMATION_GOAL_KEY, - properties: { - type: 'multi-select', - label: this.$locale.baseText('personalizationModal.whatAreYouLookingToAutomate'), - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - value: DEVOPS_AUTOMATION_CI_CD_GOAL, - label: this.$locale.baseText('personalizationModal.cicd'), - }, - { - value: DEVOPS_AUTOMATION_CLOUD_INFRASTRUCTURE_ORCHESTRATION_GOAL, - label: this.$locale.baseText( - 'personalizationModal.cloudInfrastructureOrchestration', - ), - }, - { - value: DEVOPS_AUTOMATION_DATA_SYNCING_GOAL, - label: this.$locale.baseText('personalizationModal.dataSynching'), - }, - { - value: DEVOPS_INCIDENT_RESPONSE_GOAL, - label: this.$locale.baseText('personalizationModal.incidentResponse'), - }, - { - value: DEVOPS_MONITORING_AND_ALERTING_GOAL, - label: this.$locale.baseText('personalizationModal.monitoringAndAlerting'), - }, - { - value: DEVOPS_REPORTING_GOAL, - label: this.$locale.baseText('personalizationModal.reporting'), - }, - { - value: DEVOPS_TICKETING_SYSTEMS_INTEGRATIONS_GOAL, - label: this.$locale.baseText('personalizationModal.ticketingSystemsIntegrations'), - }, - { - value: OTHER_AUTOMATION_GOAL, - label: this.$locale.baseText('personalizationModal.other'), - }, - ], - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string; - return ( - companyType !== PERSONAL_COMPANY_TYPE && - [ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role) - ); - }, - }, - { - name: DEVOPS_AUTOMATION_GOAL_OTHER_KEY, - properties: { - placeholder: this.$locale.baseText('personalizationModal.specifyYourAutomationGoal'), - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - const goals = (values as IPersonalizationLatestVersion)[DEVOPS_AUTOMATION_GOAL_KEY]; - const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string; - return ( - companyType !== PERSONAL_COMPANY_TYPE && - [ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role) && - !!goals && - goals.includes(DEVOPS_AUTOMATION_OTHER) - ); - }, - }, - { - name: MARKETING_AUTOMATION_GOAL_KEY, - properties: { - type: 'multi-select', - label: this.$locale.baseText('personalizationModal.specifySalesMarketingGoal'), - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - label: this.$locale.baseText('personalizationModal.leadGeneration'), - value: MARKETING_AUTOMATION_LEAD_GENERATION_GOAL, - }, - { - label: this.$locale.baseText('personalizationModal.customerCommunication'), - value: MARKETING_AUTOMATION_CUSTOMER_COMMUNICATION, - }, - { - label: this.$locale.baseText('personalizationModal.customerActions'), - value: MARKETING_AUTOMATION_ACTIONS, - }, - { - label: this.$locale.baseText('personalizationModal.adCampaign'), - value: MARKETING_AUTOMATION_AD_CAMPAIGN, - }, - { - label: this.$locale.baseText('personalizationModal.reporting'), - value: MARKETING_AUTOMATION_REPORTING, - }, - { - label: this.$locale.baseText('personalizationModal.dataSynching'), - value: MARKETING_AUTOMATION_DATA_SYNCHING, - }, - { - label: this.$locale.baseText('personalizationModal.other'), - value: MARKETING_AUTOMATION_OTHER, - }, - ], - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - const role = (values as IPersonalizationLatestVersion)[ROLE_KEY]; - return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_SALES_AND_MARKETING; - }, - }, - { - name: OTHER_MARKETING_AUTOMATION_GOAL_KEY, - properties: { - placeholder: this.$locale.baseText( - 'personalizationModal.specifyOtherSalesAndMarketingGoal', - ), - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - const goals = (values as IPersonalizationLatestVersion)[MARKETING_AUTOMATION_GOAL_KEY]; - const role = (values as IPersonalizationLatestVersion)[ROLE_KEY]; - return ( - companyType !== PERSONAL_COMPANY_TYPE && - role === ROLE_SALES_AND_MARKETING && - !!goals && - goals.includes(MARKETING_AUTOMATION_OTHER) - ); - }, - }, - { - name: AUTOMATION_BENEFICIARY_KEY, - properties: { - type: 'select', - label: this.$locale.baseText('personalizationModal.specifyAutomationBeneficiary'), - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - label: this.$locale.baseText('personalizationModal.myself'), - value: AUTOMATION_BENEFICIARY_SELF, - }, - { - label: this.$locale.baseText('personalizationModal.myTeam'), - value: AUTOMATION_BENEFICIARY_MY_TEAM, - }, - { - label: this.$locale.baseText('personalizationModal.otherTeams'), - value: AUTOMATION_BENEFICIARY_OTHER_TEAMS, - }, - ], - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - return companyType !== PERSONAL_COMPANY_TYPE; - }, - }, - { - name: COMPANY_SIZE_KEY, - properties: { - type: 'select', - label: this.$locale.baseText('personalizationModal.howBigIsYourCompany'), - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - label: this.$locale.baseText('personalizationModal.lessThan20People'), - value: COMPANY_SIZE_20_OR_LESS, - }, - { - label: `20-99 ${this.$locale.baseText('personalizationModal.people')}`, - value: COMPANY_SIZE_20_99, - }, - { - label: `100-499 ${this.$locale.baseText('personalizationModal.people')}`, - value: COMPANY_SIZE_100_499, - }, - { - label: `500-999 ${this.$locale.baseText('personalizationModal.people')}`, - value: COMPANY_SIZE_500_999, - }, - { - label: `1000+ ${this.$locale.baseText('personalizationModal.people')}`, - value: COMPANY_SIZE_1000_OR_MORE, - }, - { - label: this.$locale.baseText('personalizationModal.imNotUsingN8nForWork'), - value: COMPANY_SIZE_PERSONAL_USE, - }, - ], - }, - shouldDisplay(values): boolean { - const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; - return companyType !== PERSONAL_COMPANY_TYPE; - }, - }, - { - name: REPORTED_SOURCE_KEY, - properties: { - type: 'select', - label: this.$locale.baseText('personalizationModal.howDidYouHearAboutN8n'), - placeholder: this.$locale.baseText('personalizationModal.select'), - options: [ - { - label: 'Google', - value: REPORTED_SOURCE_GOOGLE, - }, - { - label: 'Twitter', - value: REPORTED_SOURCE_TWITTER, - }, - { - label: 'LinkedIn', - value: REPORTED_SOURCE_LINKEDIN, - }, - { - label: 'YouTube', - value: REPORTED_SOURCE_YOUTUBE, - }, - { - label: this.$locale.baseText('personalizationModal.friendWordOfMouth'), - value: REPORTED_SOURCE_FRIEND, - }, - { - label: this.$locale.baseText('personalizationModal.podcast'), - value: REPORTED_SOURCE_PODCAST, - }, - { - label: this.$locale.baseText('personalizationModal.event'), - value: REPORTED_SOURCE_EVENT, - }, - { - label: this.$locale.baseText('personalizationModal.otherPleaseSpecify'), - value: REPORTED_SOURCE_OTHER, - }, - ], - }, - }, - { - name: REPORTED_SOURCE_OTHER_KEY, - properties: { - placeholder: this.$locale.baseText('personalizationModal.specifyReportedSource'), - }, - shouldDisplay(values): boolean { - const reportedSource = (values as IPersonalizationLatestVersion)[REPORTED_SOURCE_KEY]; - return reportedSource === REPORTED_SOURCE_OTHER; - }, + }, + { + name: DEVOPS_AUTOMATION_GOAL_OTHER_KEY, + properties: { + placeholder: i18n.baseText('personalizationModal.specifyYourAutomationGoal'), + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + const goals = (values as IPersonalizationLatestVersion)[DEVOPS_AUTOMATION_GOAL_KEY]; + const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string; + return ( + companyType !== PERSONAL_COMPANY_TYPE && + [ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role) && + !!goals && + goals.includes(DEVOPS_AUTOMATION_OTHER) + ); + }, + }, + { + name: MARKETING_AUTOMATION_GOAL_KEY, + properties: { + type: 'multi-select', + label: i18n.baseText('personalizationModal.specifySalesMarketingGoal'), + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + label: i18n.baseText('personalizationModal.leadGeneration'), + value: MARKETING_AUTOMATION_LEAD_GENERATION_GOAL, }, - ]; - - return survey; + { + label: i18n.baseText('personalizationModal.customerCommunication'), + value: MARKETING_AUTOMATION_CUSTOMER_COMMUNICATION, + }, + { + label: i18n.baseText('personalizationModal.customerActions'), + value: MARKETING_AUTOMATION_ACTIONS, + }, + { + label: i18n.baseText('personalizationModal.adCampaign'), + value: MARKETING_AUTOMATION_AD_CAMPAIGN, + }, + { + label: i18n.baseText('personalizationModal.reporting'), + value: MARKETING_AUTOMATION_REPORTING, + }, + { + label: i18n.baseText('personalizationModal.dataSynching'), + value: MARKETING_AUTOMATION_DATA_SYNCHING, + }, + { + label: i18n.baseText('personalizationModal.other'), + value: MARKETING_AUTOMATION_OTHER, + }, + ], + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + const role = (values as IPersonalizationLatestVersion)[ROLE_KEY]; + return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_SALES_AND_MARKETING; + }, + }, + { + name: OTHER_MARKETING_AUTOMATION_GOAL_KEY, + properties: { + placeholder: i18n.baseText('personalizationModal.specifyOtherSalesAndMarketingGoal'), + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + const goals = (values as IPersonalizationLatestVersion)[MARKETING_AUTOMATION_GOAL_KEY]; + const role = (values as IPersonalizationLatestVersion)[ROLE_KEY]; + return ( + companyType !== PERSONAL_COMPANY_TYPE && + role === ROLE_SALES_AND_MARKETING && + !!goals && + goals.includes(MARKETING_AUTOMATION_OTHER) + ); + }, + }, + { + name: AUTOMATION_BENEFICIARY_KEY, + properties: { + type: 'select', + label: i18n.baseText('personalizationModal.specifyAutomationBeneficiary'), + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + label: i18n.baseText('personalizationModal.myself'), + value: AUTOMATION_BENEFICIARY_SELF, + }, + { + label: i18n.baseText('personalizationModal.myTeam'), + value: AUTOMATION_BENEFICIARY_MY_TEAM, + }, + { + label: i18n.baseText('personalizationModal.otherTeams'), + value: AUTOMATION_BENEFICIARY_OTHER_TEAMS, + }, + ], + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + return companyType !== PERSONAL_COMPANY_TYPE; }, }, - mounted() { - void this.loadDomainBlocklist(); + { + name: COMPANY_SIZE_KEY, + properties: { + type: 'select', + label: i18n.baseText('personalizationModal.howBigIsYourCompany'), + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + label: i18n.baseText('personalizationModal.lessThan20People'), + value: COMPANY_SIZE_20_OR_LESS, + }, + { + label: `20-99 ${i18n.baseText('personalizationModal.people')}`, + value: COMPANY_SIZE_20_99, + }, + { + label: `100-499 ${i18n.baseText('personalizationModal.people')}`, + value: COMPANY_SIZE_100_499, + }, + { + label: `500-999 ${i18n.baseText('personalizationModal.people')}`, + value: COMPANY_SIZE_500_999, + }, + { + label: `1000+ ${i18n.baseText('personalizationModal.people')}`, + value: COMPANY_SIZE_1000_OR_MORE, + }, + { + label: i18n.baseText('personalizationModal.imNotUsingN8nForWork'), + value: COMPANY_SIZE_PERSONAL_USE, + }, + ], + }, + shouldDisplay(values): boolean { + const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY]; + return companyType !== PERSONAL_COMPANY_TYPE; + }, }, - methods: { - closeDialog() { - this.modalBus.emit('close'); - const isPartOfOnboardingExperiment = - this.posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) === - MORE_ONBOARDING_OPTIONS_EXPERIMENT.control; - // In case the redirect to homepage for new users didn't happen - // we try again after closing the modal - if (this.$route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { - void this.$router.replace({ name: VIEWS.HOMEPAGE }); - } + { + name: REPORTED_SOURCE_KEY, + properties: { + type: 'select', + label: i18n.baseText('personalizationModal.howDidYouHearAboutN8n'), + placeholder: i18n.baseText('personalizationModal.select'), + options: [ + { + label: 'Google', + value: REPORTED_SOURCE_GOOGLE, + }, + { + label: 'Twitter', + value: REPORTED_SOURCE_TWITTER, + }, + { + label: 'LinkedIn', + value: REPORTED_SOURCE_LINKEDIN, + }, + { + label: 'YouTube', + value: REPORTED_SOURCE_YOUTUBE, + }, + { + label: i18n.baseText('personalizationModal.friendWordOfMouth'), + value: REPORTED_SOURCE_FRIEND, + }, + { + label: i18n.baseText('personalizationModal.podcast'), + value: REPORTED_SOURCE_PODCAST, + }, + { + label: i18n.baseText('personalizationModal.event'), + value: REPORTED_SOURCE_EVENT, + }, + { + label: i18n.baseText('personalizationModal.otherPleaseSpecify'), + value: REPORTED_SOURCE_OTHER, + }, + ], }, - async loadDomainBlocklist() { - try { - this.domainBlocklist = (await import('email-providers/common.json')).default; - } catch (error) {} + }, + { + name: REPORTED_SOURCE_OTHER_KEY, + properties: { + placeholder: i18n.baseText('personalizationModal.specifyReportedSource'), }, - onSave() { - this.formBus.emit('submit'); + shouldDisplay(values): boolean { + const reportedSource = (values as IPersonalizationLatestVersion)[REPORTED_SOURCE_KEY]; + return reportedSource === REPORTED_SOURCE_OTHER; }, - async onSubmit(values: IPersonalizationLatestVersion): Promise<void> { - this.isSaving = true; - - try { - const survey: IPersonalizationLatestVersion = { - ...values, - version: SURVEY_VERSION, - personalization_survey_submitted_at: new Date().toISOString(), - personalization_survey_n8n_version: this.rootStore.versionCli, - }; + }, +]); - await this.externalHooks.run('personalizationModal.onSubmit', survey); +const onSave = () => { + formBus.emit('submit'); +}; - await this.usersStore.submitPersonalizationSurvey(survey); +const closeDialog = () => { + modalBus.emit('close'); + const isPartOfOnboardingExperiment = + posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) === + MORE_ONBOARDING_OPTIONS_EXPERIMENT.control; + // In case the redirect to homepage for new users didn't happen + // we try again after closing the modal + if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { + void router.replace({ name: VIEWS.HOMEPAGE }); + } +}; - this.posthogStore.setMetadata(survey, 'user'); +const onSubmit = async (values: IPersonalizationLatestVersion) => { + isSaving.value = true; - if (Object.keys(values).length === 0) { - this.closeDialog(); - } - } catch (e) { - this.showError(e, 'Error while submitting results'); - } + try { + const completedSurvey: IPersonalizationLatestVersion = { + ...values, + version: SURVEY_VERSION, + personalization_survey_submitted_at: new Date().toISOString(), + personalization_survey_n8n_version: rootStore.versionCli, + }; - let licenseRequestSucceeded = false; - try { - if (this.registerForEnterpriseTrial && this.canRegisterForEnterpriseTrial) { - await this.usageStore.requestEnterpriseLicenseTrial(); - licenseRequestSucceeded = true; - this.$telemetry.track('User registered for self serve trial', { - email: this.usersStore.currentUser?.email, - instance_id: this.rootStore.instanceId, - }); - } - } catch (e) { - this.showError( - e, - this.$locale.baseText('personalizationModal.registerEmailForTrial.error'), - ); - } + await externalHooks.run('personalizationModal.onSubmit', completedSurvey); - this.isSaving = false; - this.closeDialog(); + await usersStore.submitPersonalizationSurvey(completedSurvey); - if (licenseRequestSucceeded) { - await this.alert( - this.$locale.baseText('personalizationModal.registerEmailForTrial.success.message'), - { - title: this.$locale.baseText( - 'personalizationModal.registerEmailForTrial.success.title', - ), - confirmButtonText: this.$locale.baseText( - 'personalizationModal.registerEmailForTrial.success.button', - ), - }, - ); - } - }, - }, -}); + posthogStore.setMetadata(completedSurvey, 'user'); + } catch (e) { + showError(e, 'Error while submitting results'); + } finally { + isSaving.value = false; + closeDialog(); + } +}; </script> <template> @@ -720,24 +615,10 @@ export default defineComponent({ :inputs="survey" :column-view="true" :event-bus="formBus" - :teleported="teleported" + :teleported="true" tag-size="small" @submit="onSubmit" /> - <n8n-card v-if="canRegisterForEnterpriseTrial"> - <n8n-checkbox v-model="registerForEnterpriseTrial"> - <i18n-t keypath="personalizationModal.registerEmailForTrial"> - <template #trial> - <strong> - {{ $locale.baseText('personalizationModal.registerEmailForTrial.enterprise') }} - </strong> - </template> - </i18n-t> - <n8n-text size="small" tag="div" color="text-light"> - {{ $locale.baseText('personalizationModal.registerEmailForTrial.notice') }} - </n8n-text> - </n8n-checkbox> - </n8n-card> </div> </template> <template #footer> diff --git a/packages/editor-ui/src/components/TagsDropdown.vue b/packages/editor-ui/src/components/TagsDropdown.vue index cae9c74627dfd..2c25e455fc52a 100644 --- a/packages/editor-ui/src/components/TagsDropdown.vue +++ b/packages/editor-ui/src/components/TagsDropdown.vue @@ -182,7 +182,7 @@ onClickOutside( () => { emit('blur'); }, - { ignore: [`.tags-dropdown-${dropdownId}`, '#tags-manager-modal'] }, + { ignore: [`.tags-dropdown-${dropdownId}`, '#tags-manager-modal'], detectIframe: true }, ); </script> @@ -199,7 +199,7 @@ onClickOutside( multiple :reserve-keyword="false" loading-text="..." - :popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId]" + :popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId].join(' ')" data-test-id="tags-dropdown" @update:model-value="onTagsUpdated" @visible-change="onVisibleChange" diff --git a/packages/editor-ui/src/components/__tests__/PersonalizationModal.spec.ts b/packages/editor-ui/src/components/__tests__/PersonalizationModal.spec.ts deleted file mode 100644 index b688af7cfcb5e..0000000000000 --- a/packages/editor-ui/src/components/__tests__/PersonalizationModal.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import PersonalizationModal from '@/components/PersonalizationModal.vue'; -import { createTestingPinia } from '@pinia/testing'; -import userEvent from '@testing-library/user-event'; -import { PERSONALIZATION_MODAL_KEY, ROLE, STORES, VIEWS } from '@/constants'; -import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils'; -import { createComponentRenderer } from '@/__tests__/render'; -import { fireEvent } from '@testing-library/vue'; -import { useUsersStore } from '@/stores/users.store'; -import { useUsageStore } from '@/stores/usage.store'; - -const pinia = createTestingPinia({ - initialState: { - [STORES.UI]: { - modalsById: { - [PERSONALIZATION_MODAL_KEY]: { open: true }, - }, - }, - [STORES.SETTINGS]: { - settings: { - templates: { - host: '', - }, - }, - }, - [STORES.USERS]: { - usersById: { - 123: { - email: 'john@doe.com', - firstName: 'John', - lastName: 'Doe', - isDefaultUser: false, - isPendingUser: false, - role: ROLE.Owner, - mfaEnabled: false, - }, - }, - currentUserId: '123', - }, - }, -}); - -const renderComponent = createComponentRenderer(PersonalizationModal, { - props: { - teleported: false, - appendToBody: false, - }, - pinia, - global: { - mocks: { - $route: { - name: VIEWS.HOMEPAGE, - }, - }, - }, -}); - -describe('PersonalizationModal.vue', () => { - beforeEach(() => { - createAppModals(); - }); - - afterEach(() => { - cleanupAppModals(); - }); - - it('should render correctly', async () => { - const { getByTestId } = renderComponent(); - - await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument()); - - const modalContent = getByTestId('personalization-form'); - expect(modalContent.querySelectorAll('.n8n-select').length).toEqual(5); - }); - - it('should display new option when role is "Devops", "Engineering", "IT", or "Sales and marketing"', async () => { - const { getByTestId } = renderComponent(); - - await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument()); - - for (const index of [3, 4, 5, 6]) { - const modalContent = getByTestId('personalization-form'); - const expectFn = expect; // So we don't break @typescript-eslint/no-loop-func - const select = modalContent.querySelectorAll('.n8n-select')[1]; - - await fireEvent.click(select); - - const item = select.querySelectorAll('.el-select-dropdown__item')[index]; - - await fireEvent.click(item); - - await retry(() => { - expectFn(modalContent.querySelectorAll('.n8n-select').length).toEqual(6); - expectFn(modalContent.querySelector('[name^="automationGoal"]')).toBeInTheDocument(); - }); - } - }); - - it('should display self serve trial option when company size is larger than 500', async () => { - const { getByTestId } = renderComponent(); - - await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument()); - - const modalContent = getByTestId('personalization-form'); - - const select = modalContent.querySelectorAll('.n8n-select')[3]; - await fireEvent.click(select); - - const item = select.querySelectorAll('.el-select-dropdown__item')[3]; - await fireEvent.click(item); - - await retry(() => { - expect(modalContent.querySelector('.card')).not.toBeNull(); - }); - }); - - it('should display send telemetry when requesting enterprise trial', async () => { - const usersStore = useUsersStore(pinia); - vi.spyOn(usersStore, 'submitPersonalizationSurvey').mockResolvedValue(); - - const usageStore = useUsageStore(pinia); - const spyLicenseTrial = vi.spyOn(usageStore, 'requestEnterpriseLicenseTrial'); - - const { getByTestId, getByRole } = renderComponent(); - - await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument()); - - const modalContent = getByTestId('personalization-form'); - - const select = modalContent.querySelectorAll('.n8n-select')[3]; - await fireEvent.click(select); - - const item = select.querySelectorAll('.el-select-dropdown__item')[3]; - await fireEvent.click(item); - - const agreeCheckbox = modalContent.querySelector('.n8n-checkbox'); - assert(agreeCheckbox); - await fireEvent.click(agreeCheckbox); - - const submitButton = getByRole('button'); - await userEvent.click(submitButton); - - await retry(() => expect(spyLicenseTrial).toHaveBeenCalled()); - }); -}); diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationSidebar.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationPanel.vue similarity index 53% rename from packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationSidebar.vue rename to packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationPanel.vue index 94ff1f406bc0b..1ae4ef0e5c4df 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationSidebar.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationPanel.vue @@ -1,16 +1,32 @@ -<script lang="ts"> +<script setup lang="ts"> +import { ref, computed } from 'vue'; import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow'; -import { defineComponent } from 'vue'; -import type { PropType } from 'vue'; -import { mapStores } from 'pinia'; import { useExecutionsStore } from '@/stores/executions.store'; -import { useWorkflowsStore } from '@/stores/workflows.store'; import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue'; import { createEventBus } from 'n8n-design-system'; import VoteButtons from '@/components/executions/workflow/VoteButtons.vue'; import { useToast } from '@/composables/useToast'; -const hasChanged = (prev: string[], curr: string[]) => { +const executionsStore = useExecutionsStore(); + +const { showError } = useToast(); + +const tagsEventBus = createEventBus(); +const isTagsEditEnabled = ref(false); +const appliedTagIds = ref<string[]>([]); +const tagsSaving = ref(false); + +const activeExecution = computed(() => { + return executionsStore.activeExecution as ExecutionSummary & { + customData?: Record<string, string>; + }; +}); + +const vote = computed(() => activeExecution.value?.annotation?.vote || null); +const tagIds = computed(() => activeExecution.value?.annotation?.tags.map((tag) => tag.id) ?? []); +const tags = computed(() => activeExecution.value?.annotation?.tags); + +const tagsHasChanged = (prev: string[], curr: string[]) => { if (prev.length !== curr.length) { return true; } @@ -19,129 +35,79 @@ const hasChanged = (prev: string[], curr: string[]) => { return curr.reduce((acc, val) => acc || !set.has(val), false); }; -export default defineComponent({ - name: 'WorkflowExecutionAnnotationSidebar', - components: { - VoteButtons, - AnnotationTagsDropdown, - }, - props: { - execution: { - type: Object as PropType<ExecutionSummary>, - default: null, - }, - loading: { - type: Boolean, - default: true, - }, - }, - - computed: { - ...mapStores(useExecutionsStore, useWorkflowsStore), - vote() { - return this.activeExecution?.annotation?.vote || null; - }, - activeExecution() { - // FIXME: this is a temporary workaround to make TS happy. activeExecution may contain customData, but it is type-casted to ExecutionSummary after fetching from the backend - return this.executionsStore.activeExecution as ExecutionSummary & { - customData?: Record<string, string>; - }; - }, - tagIds() { - return this.activeExecution?.annotation?.tags.map((tag) => tag.id) ?? []; - }, - tags() { - return this.activeExecution?.annotation?.tags; - }, - }, - setup() { - return { - ...useToast(), - }; - }, - data() { - return { - tagsEventBus: createEventBus(), - isTagsEditEnabled: false, - appliedTagIds: [] as string[], - tagsSaving: false, - }; - }, - methods: { - async onVoteClick(vote: AnnotationVote) { - if (!this.activeExecution) { - return; - } - - // If user clicked on the same vote, remove it - // so that vote buttons act as toggle buttons - const voteToSet = vote === this.vote ? null : vote; - - try { - await this.executionsStore.annotateExecution(this.activeExecution.id, { vote: voteToSet }); - } catch (e) { - this.showError(e, this.$locale.baseText('executionAnnotationView.vote.error')); - } - }, - onTagsEditEnable() { - this.appliedTagIds = this.tagIds; - this.isTagsEditEnabled = true; - - setTimeout(() => { - this.tagsEventBus.emit('focus'); - }, 0); - }, - async onTagsBlur() { - if (!this.activeExecution) { - return; - } - - const current = (this.tagIds ?? []) as string[]; - const tags = this.appliedTagIds; - - if (!hasChanged(current, tags)) { - this.isTagsEditEnabled = false; - return; - } - - if (this.tagsSaving) { - return; - } - - this.tagsSaving = true; - - try { - await this.executionsStore.annotateExecution(this.activeExecution.id, { tags }); - } catch (e) { - this.showError(e, this.$locale.baseText('executionAnnotationView.tag.error')); - } - - this.tagsSaving = false; - this.isTagsEditEnabled = false; - }, - onTagsEditEsc() { - this.isTagsEditEnabled = false; - }, - }, -}); +const onVoteClick = async (voteValue: AnnotationVote) => { + if (!activeExecution.value) { + return; + } + + const voteToSet = voteValue === vote.value ? null : voteValue; + + try { + await executionsStore.annotateExecution(activeExecution.value.id, { vote: voteToSet }); + } catch (e) { + showError(e, 'executionAnnotationView.vote.error'); + } +}; + +const onTagsEditEnable = () => { + appliedTagIds.value = tagIds.value; + isTagsEditEnabled.value = true; + + setTimeout(() => { + tagsEventBus.emit('focus'); + }, 0); +}; + +const onTagsBlur = async () => { + if (!activeExecution.value) { + return; + } + + const currentTagIds = tagIds.value ?? []; + const newTagIds = appliedTagIds.value; + + if (!tagsHasChanged(currentTagIds, newTagIds)) { + isTagsEditEnabled.value = false; + return; + } + + if (tagsSaving.value) { + return; + } + + tagsSaving.value = true; + + try { + await executionsStore.annotateExecution(activeExecution.value.id, { tags: newTagIds }); + } catch (e) { + showError(e, 'executionAnnotationView.tag.error'); + } + + tagsSaving.value = false; + isTagsEditEnabled.value = false; +}; + +const onTagsEditEsc = () => { + isTagsEditEnabled.value = false; +}; </script> <template> <div ref="container" - :class="['execution-annotation-sidebar', $style.container]" - data-test-id="execution-annotation-sidebar" + :class="['execution-annotation-panel', $style.container]" + data-test-id="execution-annotation-panel" > <div :class="$style.section"> <div :class="$style.vote"> <div>{{ $locale.baseText('generic.rating') }}</div> <VoteButtons :vote="vote" @vote-click="onVoteClick" /> </div> - <span class="tags" data-test-id="annotation-tags-container"> + <span :class="$style.tags" data-test-id="annotation-tags-container"> <AnnotationTagsDropdown v-if="isTagsEditEnabled" - v-model="appliedTagIds" ref="dropdown" + v-model="appliedTagIds" :create-enabled="true" :event-bus="tagsEventBus" :placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')" @@ -152,7 +118,7 @@ export default defineComponent({ /> <div v-else-if="tagIds.length === 0"> <span - class="add-tag add-tag-standalone clickable" + :class="[$style.addTag, $style.addTagStandalone, 'clickable']" data-test-id="new-tag-link" @click="onTagsEditEnable" > @@ -162,7 +128,10 @@ export default defineComponent({ <span v-else - class="tags-container" + :class="[ + 'tags-container', // FIXME: There are some global styles for tags relying on this classname + $style.tagsContainer, + ]" data-test-id="execution-annotation-tags" @click="onTagsEditEnable" > @@ -171,9 +140,9 @@ export default defineComponent({ {{ tag.name }} </el-tag> </span> - <span class="add-tag-wrapper"> + <span :class="$style.addTagWrapper"> <n8n-button - class="add-tag" + :class="$style.addTag" :label="`+ ` + $locale.baseText('executionAnnotationView.addTag')" type="secondary" size="mini" @@ -208,7 +177,7 @@ export default defineComponent({ </n8n-text> </div> </div> - <div v-else :class="$style.noResultsContainer" data-test-id="execution-list-empty"> + <div v-else :class="$style.noResultsContainer" data-test-id="execution-annotation-data-empty"> <n8n-text color="text-base" size="small" align="center"> <span v-html="$locale.baseText('executionAnnotationView.data.notFound')" /> </n8n-text> @@ -219,23 +188,29 @@ export default defineComponent({ <style module lang="scss"> .container { - flex: 250px 0 0; - background-color: var(--color-background-xlight); - border-left: var(--border-base); z-index: 1; + position: absolute; + bottom: 0; + right: var(--spacing-xl); + transform: translate(0, 100%); + max-height: calc(100vh - 250px); + width: 250px; + display: flex; flex-direction: column; overflow: auto; + + background-color: var(--color-background-xlight); + border: var(--border-base); + border-radius: var(--border-radius-base); } .section { - padding: var(--spacing-l); + padding: var(--spacing-s); display: flex; flex-direction: column; &:not(:last-child) { - display: flex; - padding-bottom: var(--spacing-l); border-bottom: var(--border-base); } } @@ -296,57 +271,19 @@ export default defineComponent({ } } -.executionList { - flex: 1; - overflow: auto; - margin-bottom: var(--spacing-m); - background-color: var(--color-background-xlight) !important; - - // Scrolling fader - &::before { - position: absolute; - display: block; - width: 270px; - height: 6px; - background: linear-gradient(to bottom, rgba(251, 251, 251, 1) 0%, rgba(251, 251, 251, 0) 100%); - z-index: 999; - } - - // Lower first execution card so fader is not visible when not scrolled - & > div:first-child { - margin-top: 3px; - } -} - -.infoAccordion { - position: absolute; - bottom: 0; - margin-left: calc(-1 * var(--spacing-l)); - border-top: var(--border-base); - - & > div { - width: 309px; - background-color: var(--color-background-light); - margin-top: 0 !important; - } -} - .noResultsContainer { width: 100%; margin-top: var(--spacing-s); - //text-align: center; } -</style> -<style lang="scss" scoped> -.execution-annotation-sidebar { +.execution-annotation-panel { :deep(.el-skeleton__item) { height: 60px; border-radius: 0; } } -.tags-container { +.tagsContainer { display: inline-flex; flex-wrap: wrap; align-items: center; @@ -358,10 +295,10 @@ export default defineComponent({ } } -.add-tag { - font-size: 12px; +.addTag { + font-size: var(--font-size-2xs); color: $custom-font-very-light; - font-weight: 600; + font-weight: var(--font-weight-bold); white-space: nowrap; &:hover { color: $color-primary; @@ -369,11 +306,11 @@ export default defineComponent({ } } -.add-tag-standalone { - padding: 20px 0; // to be more clickable +.addTagStandalone { + padding: var(--spacing-m) 0; // to be more clickable } -.add-tag-wrapper { +.addTagWrapper { margin-left: calc(var(--spacing-2xs) * -1); // Cancel out right margin of last tag } </style> diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue index 2944b0daddb42..fda8e084876bb 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue @@ -2,18 +2,11 @@ import { computed, watch } from 'vue'; import { onBeforeRouteLeave, useRouter } from 'vue-router'; import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue'; -import { - EnterpriseEditionFeature, - EXECUTION_ANNOTATION_EXPERIMENT, - MAIN_HEADER_TABS, - VIEWS, -} from '@/constants'; +import { MAIN_HEADER_TABS, VIEWS } from '@/constants'; import type { ExecutionFilterType, IWorkflowDb } from '@/Interface'; import type { ExecutionSummary } from 'n8n-workflow'; import { getNodeViewTab } from '@/utils/canvasUtils'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { usePostHog } from '@/stores/posthog.store'; -import { useSettingsStore } from '@/stores/settings.store'; const props = withDefaults( defineProps<{ @@ -43,18 +36,6 @@ const emit = defineEmits<{ const workflowHelpers = useWorkflowHelpers({ router: useRouter() }); const router = useRouter(); -const posthogStore = usePostHog(); -const settingsStore = useSettingsStore(); - -const isAdvancedExecutionFilterEnabled = computed( - () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters], -); -const isAnnotationEnabled = computed( - () => - isAdvancedExecutionFilterEnabled.value && - posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT), -); - const temporaryExecution = computed<ExecutionSummary | undefined>(() => props.executions.find((execution) => execution.id === props.execution?.id) ? undefined @@ -135,10 +116,6 @@ onBeforeRouteLeave(async (to, _, next) => { @stop-execution="onStopExecution" /> </div> - <WorkflowExecutionAnnotationSidebar - v-if="isAnnotationEnabled && execution" - :execution="execution" - /> </div> </template> diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue index 677046f4bdc79..8fe995853061f 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue @@ -5,13 +5,20 @@ import { ElDropdown } from 'element-plus'; import { useExecutionDebugging } from '@/composables/useExecutionDebugging'; import { useMessage } from '@/composables/useMessage'; import WorkflowPreview from '@/components/WorkflowPreview.vue'; -import { MODAL_CONFIRM, VIEWS } from '@/constants'; +import { + EnterpriseEditionFeature, + EXECUTION_ANNOTATION_EXPERIMENT, + MODAL_CONFIRM, + VIEWS, +} from '@/constants'; import type { ExecutionSummary } from 'n8n-workflow'; import type { IExecutionUIData } from '@/composables/useExecutionHelpers'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useI18n } from '@/composables/useI18n'; import { getResourcePermissions } from '@/permissions'; +import { usePostHog } from '@/stores/posthog.store'; +import { useSettingsStore } from '@/stores/settings.store'; type RetryDropdownRef = InstanceType<typeof ElDropdown>; @@ -32,6 +39,8 @@ const executionHelpers = useExecutionHelpers(); const message = useMessage(); const executionDebugging = useExecutionDebugging(); const workflowsStore = useWorkflowsStore(); +const posthogStore = usePostHog(); +const settingsStore = useSettingsStore(); const retryDropdownRef = ref<RetryDropdownRef | null>(null); const workflowId = computed(() => route.params.name as string); @@ -57,6 +66,12 @@ const isRetriable = computed( () => !!props.execution && executionHelpers.isExecutionRetriable(props.execution), ); +const isAnnotationEnabled = computed( + () => + settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters] && + posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT), +); + async function onDeleteExecution(): Promise<void> { const deleteConfirmed = await message.confirm( locale.baseText('executionDetails.confirmMessage.message'), @@ -115,6 +130,7 @@ function onRetryButtonBlur(event: FocusEvent) { :class="$style.executionDetails" :data-test-id="`execution-preview-details-${executionId}`" > + <WorkflowExecutionAnnotationPanel v-if="isAnnotationEnabled && execution" /> <div> <N8nText size="large" color="text-base" :bold="true" data-test-id="execution-time">{{ executionUIDetails?.startTime diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 6c6a68b22d7a3..96a189071bd8e 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -328,6 +328,7 @@ export const routes: RouteRecordRaw[] = [ default: NodeView, }, meta: { + nodeView: true, middleware: ['authenticated'], middlewareOptions: { authenticated: { diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index c6ca53ac2338b..9b3470d27b224 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -428,9 +428,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { async function fetchWorkflow(id: string): Promise<IWorkflowDb> { const rootStore = useRootStore(); - const workflow = await workflowsApi.getWorkflow(rootStore.restApiContext, id); - addWorkflow(workflow); - return workflow; + const workflowData = await workflowsApi.getWorkflow(rootStore.restApiContext, id); + addWorkflow(workflowData); + return workflowData; } async function getNewWorkflowData(name?: string, projectId?: string): Promise<INewWorkflowData> { diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 4f5f16caa58fe..f69d9b7eb9d84 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -5,13 +5,13 @@ import { nextTick, onActivated, onBeforeMount, - onBeforeUnmount, onDeactivated, onMounted, ref, useCssModule, watch, h, + onBeforeUnmount, } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue'; @@ -53,7 +53,9 @@ import { MAIN_HEADER_TABS, MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM, + NEW_WORKFLOW_ID, NODE_CREATOR_OPEN_SOURCES, + PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, STICKY_NODE_TYPE, VALID_WORKFLOW_IMPORT_URL_REGEX, @@ -152,7 +154,7 @@ const canvasEventBus = createEventBus<CanvasEventBusEvents>(); const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({ route, }); -const { registerCustomAction } = useGlobalLinkActions(); +const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions(); const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router }); const { updateNodePosition, @@ -201,12 +203,18 @@ const isExecutionPreview = ref(false); const canOpenNDV = ref(true); const hideNodeIssues = ref(false); -const workflowId = computed<string>(() => route.params.name as string); -const workflow = computed(() => workflowsStore.workflowsById[workflowId.value]); +const initializedWorkflowId = ref<string | undefined>(); +const workflowId = computed(() => { + const workflowIdParam = route.params.name as string; + return [PLACEHOLDER_EMPTY_WORKFLOW_ID, NEW_WORKFLOW_ID].includes(workflowIdParam) + ? undefined + : workflowIdParam; +}); -const isNewWorkflowRoute = computed(() => route.name === VIEWS.NEW_WORKFLOW); +const isNewWorkflowRoute = computed(() => route.name === VIEWS.NEW_WORKFLOW || !workflowId.value); +const isWorkflowRoute = computed(() => !!route?.meta?.nodeView); const isDemoRoute = computed(() => route.name === VIEWS.DEMO); -const isReadOnlyRoute = computed(() => route?.meta?.readOnlyCanvas === true); +const isReadOnlyRoute = computed(() => !!route?.meta?.readOnlyCanvas); const isReadOnlyEnvironment = computed(() => { return sourceControlStore.preferences.branchReadOnly; }); @@ -287,6 +295,10 @@ async function initializeRoute() { return; } + const isAlreadyInitialized = + initializedWorkflowId.value && + [NEW_WORKFLOW_ID, workflowId.value].includes(initializedWorkflowId.value); + // This function is called on route change as well, so we need to do the following: // - if the redirect is blank, then do nothing // - if the route is the template import view, then open the template @@ -296,11 +308,11 @@ async function initializeRoute() { } else if (route.name === VIEWS.TEMPLATE_IMPORT) { const templateId = route.params.id; await openWorkflowTemplate(templateId.toString()); - } else { + } else if (isWorkflowRoute.value && !isAlreadyInitialized) { historyStore.reset(); // If there is no workflow id, treat it as a new workflow - if (!workflowId.value || isNewWorkflowRoute.value) { + if (isNewWorkflowRoute.value || !workflowId.value) { if (route.meta?.nodeView === true) { await initializeWorkspaceForNewWorkflow(); } @@ -308,14 +320,14 @@ async function initializeRoute() { } await initializeWorkspaceForExistingWorkflow(workflowId.value); - } - nodeHelpers.updateNodesInputIssues(); - nodeHelpers.updateNodesCredentialsIssues(); - nodeHelpers.updateNodesParameterIssues(); + nodeHelpers.updateNodesInputIssues(); + nodeHelpers.updateNodesCredentialsIssues(); + nodeHelpers.updateNodesParameterIssues(); - await loadCredentials(); - await initializeDebugMode(); + await loadCredentials(); + await initializeDebugMode(); + } } async function initializeWorkspaceForNewWorkflow() { @@ -325,11 +337,10 @@ async function initializeWorkspaceForNewWorkflow() { workflowsStore.makeNewWorkflowShareable(); uiStore.nodeViewInitialized = true; + initializedWorkflowId.value = NEW_WORKFLOW_ID; } async function initializeWorkspaceForExistingWorkflow(id: string) { - resetWorkspace(); - try { const workflowData = await workflowsStore.fetchWorkflow(id); @@ -339,7 +350,9 @@ async function initializeWorkspaceForExistingWorkflow(id: string) { trackOpenWorkflowFromOnboardingTemplate(); } - await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject); + await projectsStore.setProjectNavActiveIdByWorkflowHomeProject( + editableWorkflow.value.homeProject, + ); collaborationStore.notifyWorkflowOpened(id); } catch (error) { @@ -350,6 +363,7 @@ async function initializeWorkspaceForExistingWorkflow(id: string) { }); } finally { uiStore.nodeViewInitialized = true; + initializedWorkflowId.value = workflowId.value; } } @@ -359,7 +373,7 @@ async function initializeWorkspaceForExistingWorkflow(id: string) { async function openWorkflow(data: IWorkflowDb) { resetWorkspace(); - titleSet(workflow.value.name, 'IDLE'); + titleSet(editableWorkflow.value.name, 'IDLE'); await initializeWorkspace(data); @@ -382,7 +396,7 @@ async function openWorkflow(data: IWorkflowDb) { function trackOpenWorkflowFromOnboardingTemplate() { telemetry.track( - `User opened workflow from onboarding template with ID ${workflow.value.meta?.onboardingId}`, + `User opened workflow from onboarding template with ID ${editableWorkflow.value.meta?.onboardingId}`, { workflow_id: workflowId.value, }, @@ -716,8 +730,8 @@ function onClickNodeAdd(source: string, sourceHandle: string) { async function loadCredentials() { let options: { workflowId: string } | { projectId: string }; - if (workflow.value) { - options = { workflowId: workflow.value.id }; + if (editableWorkflow.value) { + options = { workflowId: editableWorkflow.value.id }; } else { const queryParam = typeof route.query?.projectId === 'string' ? route.query?.projectId : undefined; @@ -917,7 +931,9 @@ function onClickConnectionAdd(connection: Connection) { */ const workflowPermissions = computed(() => { - return getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow; + return workflowId.value + ? getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow + : {}; }); const projectPermissions = computed(() => { @@ -1200,7 +1216,7 @@ async function onSourceControlPull() { loadCredentials(), ]); - if (workflowId.value !== null && !uiStore.stateIsDirty) { + if (workflowId.value && !uiStore.stateIsDirty) { const workflowData = await workflowsStore.fetchWorkflow(workflowId.value); if (workflowData) { titleSet(workflowData.name, 'IDLE'); @@ -1306,6 +1322,10 @@ async function onPostMessageReceived(messageEvent: MessageEvent) { */ function checkIfEditingIsAllowed(): boolean { + if (!initializedWorkflowId.value) { + return true; + } + if (readOnlyNotification.value?.visible) { return false; } @@ -1438,6 +1458,12 @@ function registerCustomActions() { }); } +function unregisterCustomActions() { + unregisterCustomAction('openNodeDetail'); + unregisterCustomAction('openSelectiveNodeCreator'); + unregisterCustomAction('showNodeCreator'); +} + /** * Routing */ @@ -1445,10 +1471,6 @@ function registerCustomActions() { watch( () => route.name, async () => { - if (!checkIfEditingIsAllowed()) { - return; - } - await initializeRoute(); }, ); @@ -1464,8 +1486,9 @@ onBeforeMount(() => { } }); -onMounted(async () => { +onMounted(() => { canvasStore.startLoading(); + titleReset(); resetWorkspace(); @@ -1479,6 +1502,8 @@ onMounted(async () => { .finally(() => { isLoading.value = false; canvasStore.stopLoading(); + + void externalHooks.run('nodeView.mount').catch(() => {}); }); void usersStore.showPersonalizationSurvey(); @@ -1486,34 +1511,31 @@ onMounted(async () => { checkIfRouteIsAllowed(); }); - addUndoRedoEventBindings(); - addPostMessageEventBindings(); addSourceControlEventBindings(); + addPostMessageEventBindings(); + addWorkflowSavedEventBindings(); + addBeforeUnloadEventBindings(); addImportEventBindings(); addExecutionOpenedEventBindings(); - addWorkflowSavedEventBindings(); - registerCustomActions(); +}); - // @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store - void externalHooks.run('nodeView.mount').catch(() => {}); +onActivated(async () => { + addUndoRedoEventBindings(); }); -onActivated(() => { - addBeforeUnloadEventBindings(); +onDeactivated(() => { + removeUndoRedoEventBindings(); }); onBeforeUnmount(() => { - removeUndoRedoEventBindings(); - removePostMessageEventBindings(); removeSourceControlEventBindings(); - removeImportEventBindings(); - removeExecutionOpenedEventBindings(); + removePostMessageEventBindings(); removeWorkflowSavedEventBindings(); -}); - -onDeactivated(() => { removeBeforeUnloadEventBindings(); + removeImportEventBindings(); + removeExecutionOpenedEventBindings(); + unregisterCustomActions(); collaborationStore.terminate(); }); </script> diff --git a/packages/editor-ui/src/views/WorkflowExecutionsView.vue b/packages/editor-ui/src/views/WorkflowExecutionsView.vue index a1ad39c4f3676..a9abf67ee4b59 100644 --- a/packages/editor-ui/src/views/WorkflowExecutionsView.vue +++ b/packages/editor-ui/src/views/WorkflowExecutionsView.vue @@ -8,7 +8,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { NO_NETWORK_ERROR_CODE } from '@/utils/apiUtils'; import { useToast } from '@/composables/useToast'; -import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants'; +import { NEW_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants'; import { useRoute, useRouter } from 'vue-router'; import type { ExecutionSummary } from 'n8n-workflow'; import { useDebounce } from '@/composables/useDebounce'; @@ -34,15 +34,22 @@ const loadingMore = ref(false); const workflow = ref<IWorkflowDb | undefined>(); const workflowId = computed(() => { - return (route.params.name as string) || workflowsStore.workflowId; + const workflowIdParam = route.params.name as string; + return [PLACEHOLDER_EMPTY_WORKFLOW_ID, NEW_WORKFLOW_ID].includes(workflowIdParam) + ? undefined + : workflowIdParam; }); const executionId = computed(() => route.params.executionId as string); -const executions = computed(() => [ - ...(executionsStore.currentExecutionsByWorkflowId[workflowId.value] ?? []), - ...(executionsStore.executionsByWorkflowId[workflowId.value] ?? []), -]); +const executions = computed(() => + workflowId.value + ? [ + ...(executionsStore.currentExecutionsByWorkflowId[workflowId.value] ?? []), + ...(executionsStore.executionsByWorkflowId[workflowId.value] ?? []), + ] + : [], +); const execution = computed(() => { return executions.value.find((e) => e.id === executionId.value) ?? currentExecution.value; @@ -65,13 +72,12 @@ watch( ); onMounted(async () => { - await nodeTypesStore.loadNodeTypesIfNotLoaded(); - await Promise.all([ - nodeTypesStore.loadNodeTypesIfNotLoaded(), - fetchWorkflow(), - executionsStore.initialize(workflowId.value), - ]); - await fetchExecution(); + await Promise.all([nodeTypesStore.loadNodeTypesIfNotLoaded(), fetchWorkflow()]); + + if (workflowId.value) { + await Promise.all([executionsStore.initialize(workflowId.value), fetchExecution()]); + } + await initializeRoute(); document.addEventListener('visibilitychange', onDocumentVisibilityChange); }); @@ -116,18 +122,22 @@ async function initializeRoute() { } async function fetchWorkflow() { - if (workflowsStore.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) { - try { - await workflowsStore.fetchActiveWorkflows(); - const data = await workflowsStore.fetchWorkflow(workflowId.value); - workflowHelpers.initState(data); - await nodeHelpers.addNodes(data.nodes, data.connections); - workflow.value = workflowsStore.workflow; - } catch (error) { - toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title')); + if (workflowId.value) { + // Check if we are loading the Executions tab directly, without having loaded the workflow + if (workflowsStore.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) { + try { + await workflowsStore.fetchActiveWorkflows(); + const data = await workflowsStore.fetchWorkflow(workflowId.value); + workflowHelpers.initState(data); + await nodeHelpers.addNodes(data.nodes, data.connections); + } catch (error) { + toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title')); + } } - } else { + workflow.value = workflowsStore.getWorkflowById(workflowId.value); + } else { + workflow.value = workflowsStore.workflow; } } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index d828fa58b4665..ae6ce96f70a68 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1658,6 +1658,11 @@ export interface INodeTypeBaseDescription { * due to deprecation or as a special case (e.g. Start node) */ hidden?: true; + + /** + * Whether the node will be wrapped for tool-use by AI Agents + */ + usableAsTool?: true; } export interface INodePropertyRouting { diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 635bccddfe93b..1a58de2302a3a 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -36,6 +36,7 @@ import type { NodeParameterValue, ResourceMapperValue, INodeTypeDescription, + INodeTypeBaseDescription, INodeOutputConfiguration, INodeInputConfiguration, GenericValue, @@ -351,6 +352,58 @@ const declarativeNodeOptionParameters: INodeProperties = { ], }; +/** + * Determines if the node is of INodeType + */ +export function isINodeType(obj: unknown): obj is INodeType { + return typeof obj === 'object' && obj !== null && 'execute' in obj; +} + +/** + * Modifies the description of the passed in object, such that it can be used + * as an AI Agent Tool. + * Returns the modified item (not copied) + */ +export function convertNodeToAiTool< + T extends object & { description: INodeTypeDescription | INodeTypeBaseDescription }, +>(item: T): T { + // quick helper function for typeguard down below + function isFullDescription(obj: unknown): obj is INodeTypeDescription { + return typeof obj === 'object' && obj !== null && 'properties' in obj; + } + + if (isFullDescription(item.description)) { + item.description.name += 'Tool'; + item.description.inputs = []; + item.description.outputs = [NodeConnectionType.AiTool]; + item.description.displayName += ' Tool (wrapped)'; + delete item.description.usableAsTool; + if (!item.description.properties.map((prop) => prop.name).includes('toolDescription')) { + const descProp: INodeProperties = { + displayName: 'Description', + name: 'toolDescription', + type: 'string', + default: item.description.description, + required: true, + typeOptions: { rows: 2 }, + description: + 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', + placeholder: `e.g. ${item.description.description}`, + }; + item.description.properties.unshift(descProp); + } + } + + item.description.codex = { + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + }; + return item; +} + /** * Determines if the provided node type has any output types other than the main connection type. * @param typeDescription The node's type description to check. diff --git a/packages/workflow/test/NodeHelpers.test.ts b/packages/workflow/test/NodeHelpers.test.ts index 7dd360313026c..583d423c60c74 100644 --- a/packages/workflow/test/NodeHelpers.test.ts +++ b/packages/workflow/test/NodeHelpers.test.ts @@ -13,6 +13,7 @@ import { isSingleExecution, isSubNodeType, applyDeclarativeNodeOptionParameters, + convertNodeToAiTool, } from '@/NodeHelpers'; describe('NodeHelpers', () => { @@ -3636,4 +3637,89 @@ describe('NodeHelpers', () => { expect(nodeType.description.properties).toEqual([]); }); }); + + describe('convertNodeToAiTool', () => { + let fullNodeWrapper: { description: INodeTypeDescription }; + + beforeEach(() => { + fullNodeWrapper = { + description: { + displayName: 'Test Node', + name: 'testNode', + group: ['test'], + description: 'A test node', + version: 1, + defaults: {}, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + properties: [], + }, + }; + }); + + it('should modify the name and displayName correctly', () => { + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.name).toBe('testNodeTool'); + expect(result.description.displayName).toBe('Test Node Tool (wrapped)'); + }); + + it('should update inputs and outputs', () => { + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.inputs).toEqual([]); + expect(result.description.outputs).toEqual([NodeConnectionType.AiTool]); + }); + + it('should remove the usableAsTool property', () => { + fullNodeWrapper.description.usableAsTool = true; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.usableAsTool).toBeUndefined(); + }); + + it("should add toolDescription property if it doesn't exist", () => { + const result = convertNodeToAiTool(fullNodeWrapper); + const toolDescriptionProp = result.description.properties.find( + (prop) => prop.name === 'toolDescription', + ); + expect(toolDescriptionProp).toBeDefined(); + expect(toolDescriptionProp?.type).toBe('string'); + expect(toolDescriptionProp?.default).toBe(fullNodeWrapper.description.description); + }); + + it('should not add toolDescription property if it already exists', () => { + const toolDescriptionProp: INodeProperties = { + displayName: 'Tool Description', + name: 'toolDescription', + type: 'string', + default: 'Existing description', + }; + fullNodeWrapper.description.properties = [toolDescriptionProp]; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties).toHaveLength(1); + expect(result.description.properties[0]).toEqual(toolDescriptionProp); + }); + + it('should set codex categories correctly', () => { + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.codex).toEqual({ + categories: ['AI'], + subcategories: { + AI: ['Tools'], + Tools: ['Other Tools'], + }, + }); + }); + + it('should preserve existing properties', () => { + const existingProp: INodeProperties = { + displayName: 'Existing Prop', + name: 'existingProp', + type: 'string', + default: 'test', + }; + fullNodeWrapper.description.properties = [existingProp]; + const result = convertNodeToAiTool(fullNodeWrapper); + expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription + expect(result.description.properties).toContainEqual(existingProp); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e993b188c05a..8895ba097b4b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1017,6 +1017,9 @@ importers: packages/core: dependencies: + '@langchain/core': + specifier: 0.2.18 + version: 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 @@ -1077,6 +1080,9 @@ importers: xml2js: specifier: 'catalog:' version: 0.6.2 + zod: + specifier: 'catalog:' + version: 3.23.8 devDependencies: '@types/aws4': specifier: ^1.5.1 @@ -21616,7 +21622,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: @@ -21641,7 +21647,7 @@ snapshots: eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.5.2) eslint: 8.57.0 @@ -21661,7 +21667,7 @@ snapshots: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -22531,7 +22537,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 4.0.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -25552,7 +25558,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -26438,7 +26444,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color