first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,3 @@
defaults
cover 95% in US
cover 95% in alt-EU and last 2 years and not dead and fully supports es6-module and fully supports es6-module-dynamic-import
+30
View File
@@ -0,0 +1,30 @@
.DS_Store
node_modules
/dist
/tests/e2e/videos/
/tests/e2e/screenshots/
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
# Auto-generated files
src/components.d.ts
# Build artifacts
.build/
+12
View File
@@ -0,0 +1,12 @@
/src
/tests
/.turbo
!dist
.browserslistrc
jest.config.js
postcss.config.js
vue.config.js
dist/report.html
public/
eslint.config.js
+59
View File
@@ -0,0 +1,59 @@
![n8n.io - Workflow Automation](https://user-images.githubusercontent.com/65276001/173571060-9f2f6d7b-bac0-43b6-bdb2-001da9694058.png)
# n8n-editor-ui
The UI to create and update n8n workflows
```
npm install n8n -g
```
## Project setup
```
pnpm install
```
### Compiles and hot-reloads for development
```
pnpm serve
```
### Compiles and minifies for production
```
pnpm build
```
### Run your tests
```
pnpm test
```
### Lints and fixes files
```
pnpm lint
```
### Run your end-to-end tests
```
pnpm --filter=n8n-playwright test:local
```
### Run your unit tests
```
pnpm test:unit
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## License
You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../biome.jsonc"]
// Although nothing is extended here, it is required so biome use the
// editor-ui gitignore file:
// > For now, Biome only takes the ignore file in the working directory into account.
// https://biomejs.dev/guides/integrate-in-vcs/#use-the-ignore-file
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import { defineConfig } from 'eslint/config';
import { frontendConfig } from '@n8n/eslint-config/frontend';
export default defineConfig(frontendConfig, {
rules: {
// TODO: Remove these
'n8n-local-rules/no-internal-package-import': 'warn',
'@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }],
'id-denylist': 'warn',
'no-case-declarations': 'warn',
'no-useless-escape': 'warn',
'no-prototype-builtins': 'warn',
'no-empty': 'warn',
'no-fallthrough': 'warn',
'no-extra-boolean-cast': 'warn',
'no-sparse-arrays': 'warn',
'no-control-regex': 'warn',
'import-x/extensions': 'warn',
'import-x/no-default-export': 'warn',
'import-x/order': 'off',
'import-x/no-cycle': 'warn',
'import-x/no-duplicates': 'warn',
'no-unsafe-optional-chaining': 'warn',
'@typescript-eslint/no-restricted-types': 'warn',
'@typescript-eslint/dot-notation': 'warn',
'@stylistic/lines-between-class-members': 'warn',
'@stylistic/member-delimiter-style': 'warn',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-empty-interface': 'warn',
'@typescript-eslint/no-for-in-array': 'warn',
'@typescript-eslint/no-loop-func': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-shadow': 'warn',
'@typescript-eslint/no-this-alias': 'warn',
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-var-requires': 'warn',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/prefer-optional-chain': 'warn',
'@typescript-eslint/restrict-plus-operands': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'vue/attribute-hyphenation': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/unbound-method': 'warn',
'@typescript-eslint/restrict-template-expressions': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
},
});
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
%CONFIG_TAGS%
<link rel="stylesheet" href="/{{BASE_PATH}}/static/prefers-color-scheme.css">
<script src="/{{BASE_PATH}}/static/base-path.js" type="text/javascript"></script>
<script src="/{{BASE_PATH}}/static/posthog.init.js" type="text/javascript"></script>
<title>n8n.io - Workflow Automation</title>
</head>
<body>
<noscript>
<strong
>We're sorry but the n8n Editor-UI doesn't work properly without JavaScript enabled. Please
enable it to continue.</strong
>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+150
View File
@@ -0,0 +1,150 @@
{
"name": "n8n-editor-ui",
"version": "2.11.0",
"description": "Workflow Editor UI for n8n",
"main": "index.js",
"type": "module",
"scripts": {
"clean": "rimraf dist .turbo .build",
"build": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
"build:coverage": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=14000\" BUILD_WITH_COVERAGE=true vite build",
"typecheck": "vue-tsc --noEmit",
"typecheck:watch": "vue-tsc --watch --noEmit",
"dev": "pnpm serve",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache",
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache",
"format": "biome format --write . && prettier --write . --ignore-path ../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../.prettierignore",
"serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 dev",
"test": "vitest run",
"test:dev": "vitest --silent=false"
},
"dependencies": {
"@codemirror/autocomplete": "catalog:",
"@codemirror/commands": "catalog:",
"@codemirror/lang-css": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/lang-json": "catalog:",
"@codemirror/lang-python": "catalog:",
"@codemirror/language": "catalog:",
"@codemirror/lint": "catalog:",
"@codemirror/search": "catalog:",
"@codemirror/state": "catalog:",
"@codemirror/view": "catalog:",
"@dagrejs/dagre": "^1.1.4",
"@lezer/common": "catalog:",
"@n8n/api-types": "workspace:*",
"@n8n/chat": "workspace:*",
"@n8n/chat-hub": "workspace:*",
"@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-html": "workspace:*",
"@n8n/codemirror-lang-sql": "workspace:*",
"@n8n/composables": "workspace:*",
"@n8n/constants": "workspace:*",
"@n8n/design-system": "workspace:*",
"@n8n/i18n": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/rest-api-client": "workspace:*",
"@n8n/stores": "workspace:*",
"@n8n/utils": "workspace:*",
"@pinecone-database/connect": "0.0.4",
"@replit/codemirror-indentation-markers": "^6.5.3",
"@sentry/vue": "catalog:frontend",
"@types/semver": "^7.7.0",
"@typescript/vfs": "^1.6.0",
"@vscode/markdown-it-katex": "^1.1.2",
"@vue-flow/background": "1.3.2",
"@vue-flow/controls": "1.1.3",
"@vue-flow/core": "1.48.0",
"@vue-flow/minimap": "1.5.4",
"@vue-flow/node-resizer": "1.5.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "catalog:frontend",
"ag-grid-vue3": "^34.1.1",
"array.prototype.tosorted": "1.1.4",
"axios": "catalog:",
"bowser": "2.11.0",
"change-case": "^5.4.4",
"chart.js": "^4.4.0",
"comlink": "^4.4.1",
"core-js": "^3.40.0",
"curlconverter": "^4.12.0",
"dateformat": "^3.0.3",
"element-plus": "catalog:frontend",
"email-providers": "^2.0.1",
"esprima-next": "5.8.4",
"fast-json-stable-stringify": "^2.1.0",
"file-saver": "^2.0.2",
"flatted": "catalog:",
"highlight.js": "catalog:frontend",
"humanize-duration": "^3.27.2",
"jsonpath-plus": "^10.3.0",
"lodash": "catalog:",
"luxon": "catalog:",
"n8n-workflow": "workspace:*",
"nanoid": "catalog:",
"pinia": "catalog:frontend",
"prettier": "^3.3.3",
"qrcode.vue": "^3.3.4",
"semver": "^7.5.4",
"stream-browserify": "^3.0.0",
"timeago.js": "^4.0.2",
"typescript": "catalog:",
"uuid": "catalog:",
"v-code-diff": "^1.13.1",
"v3-infinite-loading": "^1.2.2",
"vue": "catalog:frontend",
"vue-agile": "^2.0.0",
"vue-chartjs": "^5.2.0",
"vue-component-type-helpers": "^2.2.10",
"vue-github-button": "^3.1.3",
"vue-i18n": "catalog:frontend",
"vue-json-pretty": "2.6.0",
"vue-markdown-render": "catalog:frontend",
"vue-router": "catalog:frontend",
"vue-virtual-scroller": "2.0.0-beta.8",
"vue3-touch-events": "^4.1.3",
"vuedraggable": "4.1.0",
"wa-sqlite": "github:rhashimoto/wa-sqlite#779219540f66cecaa159da32b3b8936697ba10a7",
"web-tree-sitter": "0.24.3",
"xss": "catalog:"
},
"devDependencies": {
"@codecov/vite-plugin": "^1.9.1",
"@faker-js/faker": "^8.0.2",
"@iconify/json": "^2.2.349",
"@n8n/eslint-config": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@pinia/testing": "^0.1.6",
"@sentry/vite-plugin": "^4.3.0",
"@storybook/types": "^8.6.14",
"@testing-library/user-event": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1",
"@types/humanize-duration": "^3.27.1",
"@types/json-schema": "^7.0.15",
"@types/lodash": "catalog:",
"@types/uuid": "catalog:",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-vue": "catalog:frontend",
"@vitest/coverage-v8": "catalog:",
"browserslist-to-esbuild": "^2.1.1",
"fake-indexeddb": "^6.0.0",
"miragejs": "^0.1.48",
"unplugin-icons": "catalog:frontend",
"vite": "catalog:",
"vite-plugin-istanbul": "^7.2.0",
"vite-plugin-node-polyfills": "^0.24.0",
"vite-plugin-static-copy": "2.2.0",
"vite-svg-loader": "catalog:frontend",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
"vue-tsc": "catalog:frontend",
"z-vue-scan": "^0.0.35"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
// tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1 @@
window.BASE_PATH = '/{{BASE_PATH}}/';
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" fill="none"><path fill="#00B7BC" d="M47.267 22.554c-.002.31-.126.608-.344.829l-6.805 6.796a1.13 1.13 0 0 1-.828.344 1.15 1.15 0 0 1-.828-.344 1.18 1.18 0 0 1 0-1.656l5.977-5.969-1.313-1.312-1.383 1.414L28.595 35.78l-4.617.781.781-4.617.336-.336 2.563 2.555c.214.227.515.351.828.343.311.006.611-.119.828-.343a1.18 1.18 0 0 0 0-1.657l-2.563-2.562 8.25-8.273 4.29-4.29 3.757 3.711 1.617-1.617 2.258 2.258c.218.218.342.512.344.82m-17.282 15.15 3.032-3.032v8.297a1.56 1.56 0 0 1-1.563 1.563H4.298a1.56 1.56 0 0 1-1.563-1.563V14.843c0-.405.157-.794.438-1.086l6.304-6.531v5.344H6.251a1.172 1.172 0 1 0 0 2.344h4.43a1.17 1.17 0 0 0 1.171-1.172V5.468h19.602a1.56 1.56 0 0 1 1.563 1.563v13.281L22.86 30.468a1.24 1.24 0 0 0-.328.633l-1.102 6.61c-.06.373.062.753.329 1.023.22.22.517.343.828.343h.195l6.602-1.101a1.16 1.16 0 0 0 .601-.274m-21-16a1.18 1.18 0 0 0 1.172 1.172h15.266a1.172 1.172 0 1 0 0-2.344H10.157a1.17 1.17 0 0 0-1.172 1.172m10.711 14.766a1.17 1.17 0 0 0-1.172-1.172h-8.367a1.172 1.172 0 1 0 0 2.343h8.367a1.17 1.17 0 0 0 1.172-1.172m.86-7.391a1.17 1.17 0 0 0-1.172-1.172h-9.227a1.172 1.172 0 1 0 0 2.344h9.227a1.164 1.164 0 0 0 1.171-1.172M45.75 18.617a1.563 1.563 0 0 0 0-2.203l-1.64-1.64a1.563 1.563 0 0 0-2.204 0l-.93.929 3.852 3.828z" style="fill:#7d7d87;fill-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="260" preserveAspectRatio="xMidYMid"><path d="M239.184 106.203a64.72 64.72 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.72 64.72 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.67 64.67 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.77 64.77 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483m-97.56 136.338a48.4 48.4 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.6 8.6 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601M37.158 197.93a48.35 48.35 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.34 8.34 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803M23.549 85.38a48.5 48.5 0 0 1 25.58-21.333v61.39a8.29 8.29 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.82.82 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405zm179.466 41.695-63.08-36.63L161.73 77.86a.82.82 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.54 8.54 0 0 0-4.4-7.213m21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.72.72 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391zM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.6 8.6 0 0 0-4.246 7.367zm11.868-25.58L128.067 97.3l28.188 16.218v32.434l-28.086 16.218-28.188-16.218z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,41 @@
!(function (t, e) {
var o, n, p, r;
e.__SV ||
((window.posthog = e),
(e._i = []),
(e.init = function (i, s, a) {
function g(t, e) {
var o = e.split('.');
2 == o.length && ((t = t[o[0]]), (e = o[1])),
(t[e] = function () {
t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
});
}
((p = t.createElement('script')).type = 'text/javascript'),
(p.async = !0),
(p.src = s.api_host + '/static/array.js'),
(r = t.getElementsByTagName('script')[0]).parentNode.insertBefore(p, r);
var u = e;
for (
void 0 !== a ? (u = e[a] = []) : (a = 'posthog'),
u.people = u.people || [],
u.toString = function (t) {
var e = 'posthog';
return 'posthog' !== a && (e += '.' + a), t || (e += ' (stub)'), e;
},
u.people.toString = function () {
return u.toString(1) + '.people (stub)';
},
o =
'capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled getFeatureFlag onFeatureFlags reloadFeatureFlags'.split(
' ',
),
n = 0;
n < o.length;
n++
)
g(u, o[n]);
e._i.push([i, s, a]);
}),
(e.__SV = 1));
})(document, window.posthog || []);
@@ -0,0 +1,5 @@
@media (prefers-color-scheme: dark) {
body {
background-color: rgb(45, 46, 46);
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 356 KiB

@@ -0,0 +1,102 @@
#!/usr/bin/env node
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const POPULARITY_ENDPOINT =
process.env.NODE_POPULARITY_ENDPOINT ||
'https://internal-production.app.n8n.cloud/webhook/nodes-popularity-scores';
const FAIL_ON_ERROR = process.env.N8N_FAIL_ON_POPULARITY_FETCH_ERROR === 'true';
const DATA_DIR = path.join(__dirname, '..', 'data');
const OUTPUT_FILE = path.join(DATA_DIR, 'node-popularity.json');
async function ensureDataDir() {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
} catch (error) {
// Directory might already exist, that's fine
}
}
async function fetchPopularityData() {
try {
console.log('Fetching node popularity data from:', POPULARITY_ENDPOINT);
const response = await fetch(POPULARITY_ENDPOINT, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Successfully fetched popularity data for ${data.length} nodes`);
return data;
} catch (error) {
console.warn('Failed to fetch node popularity data:', error.message);
return null;
}
}
async function getExistingData() {
try {
const content = await fs.readFile(OUTPUT_FILE, 'utf-8');
return JSON.parse(content);
} catch (error) {
// File doesn't exist or is invalid
return null;
}
}
async function savePopularityData(data) {
await ensureDataDir();
await fs.writeFile(OUTPUT_FILE, JSON.stringify(data, null, 2));
console.log(`Saved popularity data to ${OUTPUT_FILE} with ${data.length} nodes`);
}
async function fallbackToExistingData() {
const existingData = await getExistingData();
if (existingData) {
console.log('Using existing cached data - no changes made');
// Don't regenerate the file, keep the existing one
} else {
console.error('No data available - neither from API nor cache');
console.error('Creating empty placeholder file to avoid build failure');
await savePopularityData([]);
}
}
async function main() {
try {
// Try to fetch fresh data
const freshData = await fetchPopularityData();
if (freshData && Array.isArray(freshData) && freshData.length > 0) {
// Save the fresh data
await savePopularityData(freshData);
} else {
// Fetching failed
if (FAIL_ON_ERROR) {
console.error('N8N_FAIL_ON_POPULARITY_FETCH_ERROR is set - failing build');
process.exit(1);
}
// Check if we have existing data
console.log('API unavailable, checking for existing cached data');
await fallbackToExistingData();
}
} catch (error) {
console.error('Error in fetch-node-popularity script:', error);
if (FAIL_ON_ERROR) {
console.error('N8N_FAIL_ON_POPULARITY_FETCH_ERROR is set - failing build');
process.exit(1);
}
await fallbackToExistingData();
}
}
main();
@@ -0,0 +1,980 @@
import type { NotificationOptions as ElementNotificationOptions } from 'element-plus';
import type {
FrontendSettings,
IUserManagementSettings,
IVersionNotificationSettings,
Role,
} from '@n8n/api-types';
import type { ILogInStatus } from '@/features/settings/users/users.types';
import type { IUsedCredential } from '@/features/credentials/credentials.types';
import type { Scope } from '@n8n/permissions';
import type { NodeCreatorTag, IconName, BinaryMetadata } from '@n8n/design-system';
import type {
GenericValue,
IConnections,
IDataObject,
INode,
INodeIssues,
INodeParameters,
INodeTypeDescription,
IPinData,
IRunData,
IWorkflowSettings as IWorkflowSettingsWorkflow,
INodeListSearchItems,
NodeParameterValueType,
IDisplayOptions,
FeatureFlags,
ITelemetryTrackProperties,
WorkflowSettings,
WorkflowSettingsBinaryMode,
INodeExecutionData,
NodeConnectionType,
StartNodeData,
ITaskData,
ISourceData,
PublicInstalledPackage,
IDestinationNode,
AgentRequestQuery,
} from 'n8n-workflow';
import type { Version } from '@n8n/rest-api-client/api/versions';
import type { Cloud, InstanceUsage } from '@n8n/rest-api-client/api/cloudPlans';
import type {
WorkflowMetadata,
WorkflowDataCreate,
WorkflowDataUpdate,
} from '@n8n/rest-api-client/api/workflows';
import type { ITag } from '@n8n/rest-api-client/api/tags';
import type {
AI_NODE_CREATOR_VIEW,
TRIGGER_NODE_CREATOR_VIEW,
REGULAR_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_UNCATEGORIZED_CATEGORY,
AI_EVALUATION,
HUMAN_IN_THE_LOOP_CATEGORY,
} from '@/app/constants';
import type { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import type { BulkCommand, Undoable } from '@/app/models/history';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
import type {
BaseFolderItem,
FolderListItem,
ResourceParentFolder,
} from '@/features/core/folders/folders.types';
import type { WorkflowHistory } from '@n8n/rest-api-client/api/workflowHistory';
export * from '@n8n/design-system/types';
declare global {
interface Window {
posthog?: {
init(
key: string,
options?: {
api_host?: string;
autocapture?: boolean;
disable_session_recording?: boolean;
debug?: boolean;
bootstrap?: {
distinctId?: string;
isIdentifiedID?: boolean;
featureFlags: FeatureFlags;
};
session_recording?: {
maskAllInputs?: boolean;
maskInputFn?: ((text: string, element?: HTMLElement) => string) | null;
};
},
): void;
isFeatureEnabled?(flagName: string): boolean;
getFeatureFlag?(flagName: string): boolean | string;
identify?(
id: string,
userProperties?: Record<string, string | number>,
userPropertiesOnce?: Record<string, string>,
): void;
reset?(resetDeviceId?: boolean): void;
onFeatureFlags?(callback: (keys: string[], map: FeatureFlags) => void): void;
reloadFeatureFlags?(): void;
capture?(event: string, properties: IDataObject): void;
register?(metadata: IDataObject): void;
people?: {
set?(metadata: IDataObject): void;
};
debug?(): void;
get_session_id?(): string | null;
};
analytics?: {
identify(userId: string): void;
track(event: string, properties?: ITelemetryTrackProperties): void;
page(category: string, name: string, properties?: ITelemetryTrackProperties): void;
};
featureFlags?: {
getAll: () => FeatureFlags;
getVariant: (name: string) => string | boolean | undefined;
override: (name: string, value: string) => void;
};
}
}
export type EndpointStyle = {
width?: number;
height?: number;
fill?: string;
stroke?: string;
outlineStroke?: string;
lineWidth?: number;
hover?: boolean;
showOutputLabel?: boolean;
size?: string;
hoverMessage?: string;
};
export interface IUpdateInformation<T extends NodeParameterValueType = NodeParameterValueType> {
name: string;
key?: string;
value: T;
node?: string;
oldValue?: string | number;
type?: 'optionsOrderChanged';
}
export interface INodeUpdatePropertiesInformation {
name: string; // Node-Name
properties: Partial<INodeUi>;
}
export type XYPosition = [number, number];
export type DraggableMode = 'mapping' | 'panel-resize' | 'move';
export interface INodeUi extends INode {
position: XYPosition;
color?: string;
notes?: string;
issues?: INodeIssues;
name: string;
pinData?: IDataObject;
draggable?: boolean;
placeholder?: boolean;
}
export interface INodeTypesMaxCount {
[key: string]: {
exist: number;
max: number;
nodeNames: string[];
};
}
export interface IAiDataContent {
data: INodeExecutionData[] | null;
inOut: 'input' | 'output';
type: NodeConnectionType;
source?: Array<ISourceData | null>;
metadata: {
executionTime: number;
startTime: number;
subExecution?: {
workflowId: string;
executionId: string;
};
};
}
export interface IStartRunData {
workflowId: string;
startNodes?: StartNodeData[];
destinationNode?: IDestinationNode;
runData?: IRunData;
dirtyNodeNames?: string[];
triggerToStartFrom?: {
name: string;
data?: ITaskData;
};
chatSessionId?: string;
agentRequest?: {
query: AgentRequestQuery;
tool: {
name: NodeParameterValueType;
};
};
}
export interface ITableData {
columns: string[];
data: GenericValue[][];
hasJson: { [key: string]: boolean };
metadata: {
hasExecutionIds: boolean;
data: Array<INodeExecutionData['metadata'] | undefined>;
};
}
/**
* Workflow data with mandatory `templateId`
* This is used to identify sample workflows that we create for onboarding
*/
export interface WorkflowDataWithTemplateId extends Omit<WorkflowDataCreate, 'meta'> {
meta: WorkflowMetadata & {
templateId: Required<WorkflowMetadata>['templateId'];
};
}
export interface IWorkflowToShare extends WorkflowDataUpdate {
meta: WorkflowMetadata;
}
export interface NewWorkflowResponse {
name: string;
defaultSettings: IWorkflowSettings;
}
export interface INewWorkflowData {
name: string;
}
// Almost identical to cli.Interfaces.ts
export interface IWorkflowDb {
id: string;
name: string;
description?: string | null;
active: boolean;
isArchived: boolean;
createdAt: number | string;
updatedAt: number | string;
nodes: INodeUi[];
connections: IConnections;
settings?: IWorkflowSettings;
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
pinData?: IPinData;
sharedWithProjects?: ProjectSharingData[];
homeProject?: ProjectSharingData;
scopes?: Scope[];
versionId: string;
activeVersionId: string | null;
usedCredentials?: IUsedCredential[];
meta?: WorkflowMetadata;
parentFolder?: {
id: string;
name: string;
parentFolderId: string | null;
createdAt?: string;
updatedAt?: string;
};
activeVersion?: WorkflowHistory | null;
checksum?: string;
}
// For workflow list we don't need the full workflow data
export type BaseResource = {
id: string;
name: string;
};
export type FolderResource = BaseFolderItem & {
resourceType: 'folder';
};
export type WorkflowResource = BaseResource & {
resourceType: 'workflow';
description?: string;
updatedAt: string;
createdAt: string;
active: boolean;
activeVersionId: string | null;
isArchived: boolean;
homeProject?: ProjectSharingData;
scopes?: Scope[];
tags?: ITag[] | string[];
sharedWithProjects?: ProjectSharingData[];
readOnly: boolean;
parentFolder?: ResourceParentFolder;
settings?: Partial<IWorkflowSettings>;
hasResolvableCredentials?: boolean;
};
export type VariableResource = BaseResource & {
resourceType: 'variable';
key?: string;
value?: string;
project?: { id: string; name: string };
};
export type CredentialsResource = BaseResource & {
resourceType: 'credential';
updatedAt: string;
createdAt: string;
type: string;
homeProject?: ProjectSharingData;
scopes?: Scope[];
sharedWithProjects?: ProjectSharingData[];
readOnly: boolean;
needsSetup: boolean;
isGlobal?: boolean;
isResolvable?: boolean;
};
// Base resource types that are always available
export type CoreResource =
| WorkflowResource
| FolderResource
| CredentialsResource
| VariableResource;
// This interface can be extended by modules to add their own resource types
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ModuleResources {}
// The Resource type is the union of core resources and any module resources
export type Resource = CoreResource | ModuleResources[keyof ModuleResources];
export type BaseFilters = {
search: string;
homeProject: string;
[key: string]: boolean | string | string[];
};
export type SortingAndPaginationUpdates = {
page?: number;
pageSize?: number;
sort?: string;
};
export type WorkflowListItem = Omit<
IWorkflowDb,
'nodes' | 'connections' | 'pinData' | 'usedCredentials' | 'meta'
> & {
resource: 'workflow';
description?: string;
hasResolvableCredentials?: boolean;
};
export type WorkflowListResource = WorkflowListItem | FolderListItem;
// Identical to cli.Interfaces.ts
export interface IWorkflowShortResponse {
id: string;
name: string;
active: boolean;
activeVersionId: string | null;
createdAt: number | string;
updatedAt: number | string;
tags: ITag[];
}
export interface IWorkflowsShareResponse {
id: string;
createdAt: number | string;
updatedAt: number | string;
sharedWithProjects?: ProjectSharingData[];
homeProject?: ProjectSharingData;
}
// Identical or almost identical to cli.Interfaces.ts
export interface IActivationError {
time: number;
error: {
message: string;
};
}
export interface IShareWorkflowsPayload {
shareWithIds: string[];
}
export const enum UserManagementAuthenticationMethod {
Email = 'email',
Ldap = 'ldap',
Saml = 'saml',
Oidc = 'oidc',
}
export interface IPermissionGroup {
loginStatus?: ILogInStatus[];
role?: Role[];
}
export interface IPermissionAllowGroup extends IPermissionGroup {
shouldAllow?: () => boolean;
}
export interface IPermissionDenyGroup extends IPermissionGroup {
shouldDeny?: () => boolean;
}
export interface IPermissions {
allow?: IPermissionAllowGroup;
deny?: IPermissionDenyGroup;
}
export interface IUserPermissions {
[category: string]: {
[permission: string]: IPermissions;
};
}
export type WorkflowCallerPolicyDefaultOption = 'any' | 'none' | 'workflowsFromAList';
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
errorWorkflow?: string;
timezone?: string;
executionTimeout?: number;
maxExecutionTimeout?: number;
callerIds?: string;
callerPolicy?: WorkflowSettings.CallerPolicy;
executionOrder: NonNullable<IWorkflowSettingsWorkflow['executionOrder']>;
binaryMode?: WorkflowSettingsBinaryMode;
availableInMCP?: boolean;
}
export interface ITimeoutHMS {
hours: number;
minutes: number;
seconds: number;
}
export type ExtractActionKeys<T> = T extends SimplifiedNodeType ? T['name'] : never;
export type ActionsRecord<T extends SimplifiedNodeType[]> = {
[K in ExtractActionKeys<T[number]>]: ActionTypeDescription[];
};
export type SimplifiedNodeType = Pick<
INodeTypeDescription,
| 'displayName'
| 'description'
| 'name'
| 'group'
| 'icon'
| 'iconUrl'
| 'iconColor'
| 'badgeIconUrl'
| 'codex'
| 'defaults'
| 'outputs'
> & {
tag?: NodeCreatorTag;
};
export interface SubcategoryItemProps {
description?: string;
iconType?: string;
icon?: IconName;
iconProps?: {
color?: string;
};
panelClass?: string;
title?: string;
subcategory?: string;
defaults?: INodeParameters;
forceIncludeNodes?: string[];
sections?: string[];
items?: INodeCreateElement[];
new?: boolean;
hideActions?: boolean;
actionsFilter?: (items: ActionTypeDescription[]) => ActionTypeDescription[];
}
export interface ViewItemProps {
title: string;
description: string;
icon: string;
tag?: NodeCreatorTag;
borderless?: boolean;
}
export interface LabelItemProps {
key: string;
}
export interface LinkItemProps {
url: string;
key: string;
newTab?: boolean;
title: string;
description: string;
icon: string;
tag?: NodeCreatorTag;
}
export interface OpenTemplateItemProps {
templateId: string;
title: string;
description: string;
nodes?: INodeTypeDescription[];
icon?: string;
tag?: NodeCreatorTag;
compact?: boolean;
}
export interface ActionTypeDescription extends SimplifiedNodeType {
displayOptions?: IDisplayOptions;
values?: IDataObject;
actionKey: string;
outputConnectionType?: NodeConnectionType;
codex: {
label: string;
categories: string[];
};
}
export interface CategoryItemProps {
name: string;
count: number;
}
export interface CreateElementBase {
uuid?: string;
key: string;
}
export interface NodeCreateElement extends CreateElementBase {
type: 'node';
subcategory: string;
resource?: string;
operation?: string;
properties: SimplifiedNodeType;
}
export interface CategoryCreateElement extends CreateElementBase {
type: 'category';
subcategory: string;
properties: CategoryItemProps;
}
export interface SubcategoryCreateElement extends CreateElementBase {
type: 'subcategory';
properties: SubcategoryItemProps;
}
export interface SectionCreateElement extends CreateElementBase {
type: 'section';
title: string;
children: INodeCreateElement[];
/**
* Whether to show a separator at the bottom of the expanded section
*/
showSeparator?: boolean;
}
export interface ViewCreateElement extends CreateElementBase {
type: 'view';
properties: ViewItemProps;
}
export interface LabelCreateElement extends CreateElementBase {
type: 'label';
subcategory: string;
properties: LabelItemProps;
}
export interface LinkCreateElement extends CreateElementBase {
type: 'link';
properties: LinkItemProps;
}
export interface OpenTemplateElement extends CreateElementBase {
type: 'openTemplate';
properties: OpenTemplateItemProps;
}
export interface ActionCreateElement extends CreateElementBase {
type: 'action';
subcategory: string;
properties: ActionTypeDescription;
}
export type INodeCreateElement =
| NodeCreateElement
| CategoryCreateElement
| SubcategoryCreateElement
| SectionCreateElement
| ViewCreateElement
| LabelCreateElement
| ActionCreateElement
| LinkCreateElement
| OpenTemplateElement;
export type NodeTypeSelectedPayload = {
type: string;
parameters?: {
resource?: string;
operation?: string;
language?: string;
};
actionName?: string;
};
export interface SubcategorizedNodeTypes {
[subcategory: string]: INodeCreateElement[];
}
export interface INodeMetadata {
parametersLastUpdatedAt?: number;
pinnedDataLastUpdatedAt?: number;
pinnedDataLastRemovedAt?: number;
pristine: boolean;
}
export interface NodeMetadataMap {
[nodeName: string]: INodeMetadata;
}
export interface CommunityPackageMap {
[name: string]: PublicInstalledPackage;
}
export type Modals = {
[CREDENTIAL_EDIT_MODAL_KEY]: NewCredentialsModal;
[key: string]: ModalState;
};
export type ModalKey = keyof Modals;
export type ModalState = {
open: boolean;
mode?: string | null;
data?: Record<string, unknown>;
activeId?: string | null;
curlCommand?: string;
httpNodeParameters?: string;
};
export interface NewCredentialsModal extends ModalState {
showAuthSelector?: boolean;
forceManualMode?: boolean;
}
export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema' | 'html' | 'ai';
export interface TargetItem {
nodeName: string;
itemIndex: number;
runIndex: number;
outputIndex: number;
}
export type TargetNodeParameterContext = {
nodeName: string;
parameterPath: string;
};
export interface NotificationOptions extends Partial<ElementNotificationOptions> {
message: string | ElementNotificationOptions['message'];
}
export type NodeFilterType =
| typeof REGULAR_NODE_CREATOR_VIEW
| typeof TRIGGER_NODE_CREATOR_VIEW
| typeof AI_NODE_CREATOR_VIEW
| typeof AI_OTHERS_NODE_CREATOR_VIEW
| typeof AI_UNCATEGORIZED_CATEGORY
| typeof AI_EVALUATION
| typeof HUMAN_IN_THE_LOOP_CATEGORY;
export type NodeCreatorOpenSource =
| ''
| 'context_menu'
| 'no_trigger_execution_tooltip'
| 'plus_endpoint'
| 'add_input_endpoint'
| 'trigger_placeholder_button'
| 'node_shortcut'
| 'replace_node_action'
| 'node_connection_action'
| 'node_connection_drop'
| 'notice_error_message'
| 'add_node_button'
| 'add_evaluation_trigger_button'
| 'add_evaluation_node_button'
| 'templates_callout';
export interface INodeCreatorState {
itemsFilter: string;
showScrim: boolean;
rootViewHistory: NodeFilterType[];
selectedView: NodeFilterType;
openSource: NodeCreatorOpenSource;
}
export interface ISettingsState {
initialized: boolean;
settings: FrontendSettings;
userManagement: IUserManagementSettings;
templatesEndpointHealthy: boolean;
api: {
enabled: boolean;
latestVersion: number;
path: string;
swaggerUi: {
enabled: boolean;
};
};
ldap: {
loginLabel: string;
loginEnabled: boolean;
};
saml: {
loginLabel: string;
loginEnabled: boolean;
};
mfa: {
enabled: boolean;
};
saveDataErrorExecution: WorkflowSettings.SaveDataExecution;
saveDataSuccessExecution: WorkflowSettings.SaveDataExecution;
saveManualExecutions: boolean;
saveDataProgressExecution: boolean;
}
export type NodeTypesByTypeNameAndVersion = {
[nodeType: string]: {
[version: number]: INodeTypeDescription;
};
};
export interface INodeTypesState {
nodeTypes: NodeTypesByTypeNameAndVersion;
}
export interface IVersionsState {
versionNotificationSettings: IVersionNotificationSettings;
nextVersions: Version[];
currentVersion: Version | undefined;
}
export interface IWorkflowsMap {
[name: string]: IWorkflowDb;
}
export interface CommunityNodesState {
availablePackageCount: number;
installedPackages: CommunityPackageMap;
}
export interface IZoomConfig {
scale: number;
offset: XYPosition;
origin?: XYPosition;
}
export interface IBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface ITab<Value extends string | number = string | number> {
value: Value;
label?: string;
href?: string;
icon?: IconName;
align?: 'right';
tooltip?: string;
notification?: boolean;
}
export interface ITabBarItem {
value: string;
label: string;
disabled?: boolean;
}
export interface IResourceLocatorResultExpanded extends INodeListSearchItems {
linkAlt?: string;
isArchived?: boolean;
active?: boolean;
}
export interface CurlToJSONResponse {
'parameters.url': string;
'parameters.authentication': string;
'parameters.method': string;
'parameters.sendHeaders': boolean;
'parameters.headerParameters.parameters.0.name': string;
'parameters.headerParameters.parameters.0.value': string;
'parameters.sendQuery': boolean;
'parameters.sendBody': boolean;
}
export interface HistoryState {
redoStack: Undoable[];
undoStack: Undoable[];
currentBulkAction: BulkCommand | null;
bulkInProgress: boolean;
}
export type Basic = string | number | boolean;
export type Primitives = Basic | bigint | symbol;
export type Optional<T> = T | undefined | null;
export type SchemaType =
| 'string'
| 'number'
| 'boolean'
| 'bigint'
| 'symbol'
| 'array'
| 'object'
| 'function'
| 'null'
| 'undefined'
| 'binary';
export type Schema = {
type: SchemaType;
key?: string;
value: string | Schema[];
path: string;
binaryData?: BinaryMetadata;
};
export type NodeAuthenticationOption = {
name: string;
value: string;
displayOptions?: IDisplayOptions;
};
export interface CloudPlanState {
initialized: boolean;
data: Cloud.PlanData | null;
usage: InstanceUsage | null;
loadingPlan: boolean;
}
export type CloudPlanAndUsageData = Cloud.PlanData & { usage: InstanceUsage };
export type CloudUpdateLinkSourceType =
| 'advanced-permissions'
| 'canvas-nav'
| 'concurrency'
| 'custom-data-filter'
| 'workflow_sharing'
| 'credential_sharing'
| 'settings-n8n-api'
| 'audit-logs'
| 'ldap'
| 'log-streaming'
| 'source-control'
| 'sso'
| 'usage_page'
| 'settings-users'
| 'variables'
| 'community-nodes'
| 'workflow-history'
| 'worker-view'
| 'external-secrets'
| 'rbac'
| 'debug'
| 'insights'
| 'evaluations'
| 'ai-builder-sidebar'
| 'ai-builder-canvas'
| 'custom-roles'
| 'custom-roles-selector'
| 'custom-roles-list'
| 'main-sidebar'
| 'chat-hub'
| 'empty-state-builder-prompt';
export type UTMCampaign =
| 'upgrade-custom-data-filter'
| 'upgrade-concurrency'
| 'upgrade-workflow-sharing'
| 'upgrade-credentials-sharing'
| 'upgrade-api'
| 'upgrade-audit-logs'
| 'upgrade-ldap'
| 'upgrade-log-streaming'
| 'upgrade-source-control'
| 'upgrade-sso'
| 'open'
| 'upgrade-users'
| 'upgrade-variables'
| 'upgrade-community-nodes'
| 'upgrade-workflow-history'
| 'upgrade-advanced-permissions'
| 'upgrade-worker-view'
| 'upgrade-external-secrets'
| 'upgrade-rbac'
| 'upgrade-debug'
| 'upgrade-insights'
| 'upgrade-evaluations'
| 'upgrade-builder'
| 'upgrade-custom-roles'
| 'upgrade-canvas-nav'
| 'upgrade-main-sidebar';
export type AddedNode = {
type: string;
openDetail?: boolean;
isAutoAdd?: boolean;
positionOffset?: XYPosition;
actionName?: string;
} & Partial<INodeUi>;
export type AddedNodeConnection = {
from: { nodeIndex: number; outputIndex?: number; type?: NodeConnectionType };
to: { nodeIndex: number; inputIndex?: number; type?: NodeConnectionType };
};
export type AddedNodesAndConnections = {
nodes: AddedNode[];
connections: AddedNodeConnection[];
};
export type ToggleNodeCreatorOptions = {
createNodeActive: boolean;
source?: NodeCreatorOpenSource;
nodeCreatorView?: NodeFilterType;
hasAddedNodes?: boolean;
connectionType?: NodeConnectionType;
};
export type AppliedThemeOption = 'light' | 'dark';
export type ThemeOption = AppliedThemeOption | 'system';
export type EnterpriseEditionFeatureKey =
| 'AdvancedExecutionFilters'
| 'Sharing'
| 'Ldap'
| 'LogStreaming'
| 'Variables'
| 'Saml'
| 'Oidc'
| 'SourceControl'
| 'ExternalSecrets'
| 'AuditLogs'
| 'DebugInEditor'
| 'WorkerView'
| 'AdvancedPermissions'
| 'ApiKeyScopes'
| 'EnforceMFA'
| 'NamedVersions'
| 'Provisioning'
| 'PersonalSpacePolicy'
| 'CustomRoles';
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;
export type Draggable = {
isDragging: boolean;
type: string;
data: string;
dimensions: DOMRect | null;
activeTarget: { id: string; stickyPosition: null | XYPosition } | null;
};
export interface LlmTokenUsageData {
completionTokens: number;
promptTokens: number;
totalTokens: number;
isEstimate: boolean;
}
export interface WorkflowValidationIssue {
node: string;
type: string;
value: string | string[];
}
@@ -0,0 +1,16 @@
import { faker } from '@faker-js/faker';
import type { IUser } from '@n8n/rest-api-client/api/users';
import { SignInType } from '@/app/constants';
export const createUser = (overrides?: Partial<IUser>): IUser => ({
id: faker.string.uuid(),
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
isDefaultUser: false,
isPending: false,
isPendingUser: false,
mfaEnabled: false,
signInType: SignInType.EMAIL,
...overrides,
});
@@ -0,0 +1,177 @@
import type { FrontendSettings } from '@n8n/api-types';
export const defaultSettings: FrontendSettings = {
ai: {
allowSendingParameterValues: true,
},
inE2ETests: false,
databaseType: 'sqlite',
isDocker: false,
pruning: {
isEnabled: false,
maxAge: 0,
maxCount: 0,
},
allowedModules: {},
communityNodesEnabled: false,
unverifiedCommunityNodesEnabled: true,
defaultLocale: '',
endpointForm: '',
endpointFormTest: '',
endpointFormWaiting: '',
endpointMcp: '',
endpointMcpTest: '',
endpointWebhook: '',
endpointWebhookTest: '',
endpointWebhookWaiting: '',
endpointHealth: '/healthz',
enterprise: {
sharing: false,
ldap: false,
oidc: false,
saml: false,
mfaEnforcement: false,
logStreaming: false,
debugInEditor: false,
advancedExecutionFilters: false,
variables: false,
sourceControl: false,
auditLogs: false,
showNonProdBanner: false,
binaryDataS3: false,
externalSecrets: false,
workerView: false,
advancedPermissions: false,
apiKeyScopes: false,
workflowDiffs: false,
namedVersions: false,
provisioning: true,
projects: {
team: {
limit: 1,
},
},
customRoles: false,
personalSpacePolicy: false,
},
executionMode: 'regular',
isMultiMain: false,
executionTimeout: 0,
hideUsagePage: false,
hiringBannerEnabled: false,
instanceId: '',
license: { environment: 'development', consumerId: 'unknown' },
logLevel: 'info',
maxExecutionTimeout: 0,
oauthCallbackUrls: { oauth1: '', oauth2: '' },
personalizationSurveyEnabled: false,
releaseChannel: 'stable',
posthog: {
apiHost: '',
apiKey: '',
autocapture: false,
debug: false,
disableSessionRecording: false,
enabled: false,
proxy: '',
},
publicApi: {
enabled: false,
latestVersion: 0,
path: '',
swaggerUi: { enabled: false },
},
pushBackend: 'websocket',
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveManualExecutions: false,
saveExecutionProgress: false,
sso: {
ldap: { loginEnabled: false, loginLabel: '' },
saml: { loginEnabled: false, loginLabel: '' },
oidc: { loginEnabled: false, loginUrl: '', callbackUrl: '' },
},
telemetry: {
enabled: false,
},
templates: { enabled: false, host: '' },
timezone: '',
urlBaseEditor: '',
urlBaseWebhook: '',
authCookie: {
secure: false,
},
userManagement: {
showSetupOnFirstLoad: false,
smtpSetup: true,
authenticationMethod: 'email',
quota: 10,
},
versionCli: '',
nodeJsVersion: '',
nodeEnv: '',
concurrency: -1,
versionNotifications: {
enabled: true,
endpoint: '',
infoUrl: '',
whatsNewEnabled: true,
whatsNewEndpoint: '',
},
dataTables: {
maxSize: 0,
},
workflowCallerPolicyDefaultOption: 'any',
workflowTagsDisabled: false,
variables: {
limit: -1,
},
deployment: {
type: 'default',
},
banners: {
dismissed: [],
},
binaryDataMode: 'default',
previewMode: false,
mfa: {
enabled: false,
enforced: false,
},
askAi: {
enabled: false,
},
aiBuilder: {
enabled: false,
setup: false,
},
workflowHistory: {
pruneTime: 0,
licensePruneTime: 0,
},
security: {
blockFileAccessToN8nFiles: false,
},
aiAssistant: {
enabled: false,
setup: false,
},
aiCredits: {
enabled: false,
credits: 0,
setup: false,
},
easyAIWorkflowOnboarded: false,
folders: {
enabled: false,
},
evaluation: {
quota: 0,
},
activeModules: [],
envFeatureFlags: {},
dynamicBanners: {
endpoint: 'https://api.n8n.io/api/banners',
enabled: true,
},
};
@@ -0,0 +1,343 @@
import type {
INodeType,
INodeTypeData,
INodeTypes,
IConnections,
IDataObject,
INode,
IPinData,
IWorkflowSettings,
LoadedClass,
INodeTypeDescription,
INodeIssues,
ITaskData,
INodeProperties,
} from 'n8n-workflow';
import {
createEmptyRunExecutionData,
FORM_TRIGGER_NODE_TYPE,
NodeConnectionTypes,
NodeHelpers,
Workflow,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { mock } from 'vitest-mock-extended';
import {
AGENT_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
CODE_NODE_TYPE,
EXECUTABLE_TRIGGER_NODE_TYPES,
MANUAL_TRIGGER_NODE_TYPE,
NO_OP_NODE_TYPE,
OPEN_AI_CHAT_MODEL_NODE_TYPE,
SET_NODE_TYPE,
SIMULATE_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/app/constants';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { CanvasNodeRenderType } from '@/features/workflows/canvas/canvas.types';
import type { FrontendSettings } from '@n8n/api-types';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
export const mockNode = ({
id = uuid(),
name,
type,
position = [0, 0],
disabled = false,
issues = undefined,
typeVersion = 1,
parameters = {},
draggable = undefined,
placeholder = undefined,
}: {
id?: INodeUi['id'];
name: INodeUi['name'];
type: INodeUi['type'];
position?: INodeUi['position'];
disabled?: INodeUi['disabled'];
issues?: INodeIssues;
typeVersion?: INodeUi['typeVersion'];
parameters?: INodeUi['parameters'];
draggable?: INodeUi['draggable'];
placeholder?: INodeUi['placeholder'];
}) =>
mock<INodeUi>({
id,
name,
type,
position,
disabled,
issues,
typeVersion,
parameters,
draggable,
placeholder,
});
export const mockNodeTypeDescription = ({
name = SET_NODE_TYPE,
displayName = name,
icon = 'fa:pen',
version = 1,
credentials = [],
inputs = [NodeConnectionTypes.Main],
outputs = [NodeConnectionTypes.Main],
codex = undefined,
properties = [],
group,
hidden,
description,
webhooks,
eventTriggerDescription,
maxNodes,
}: Partial<INodeTypeDescription> = {}) =>
mock<INodeTypeDescription>({
name,
icon,
displayName,
description: description ?? '',
version,
defaults: {
name,
},
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
properties: properties as [],
maxNodes: maxNodes ?? Infinity,
group: (group ?? EXECUTABLE_TRIGGER_NODE_TYPES.includes(name)) ? ['trigger'] : [],
inputs,
outputs,
codex,
credentials,
documentationUrl: 'https://docs',
iconUrl: 'nodes/test-node/icon.svg',
webhooks,
parameterPane: undefined,
hidden,
eventTriggerDescription,
});
export const mockLoadedNodeType = (name: string) => {
const config: Partial<INodeTypeDescription> = { name };
// Configure special node types with their correct connection types
if (name === OPEN_AI_CHAT_MODEL_NODE_TYPE) {
config.outputs = [NodeConnectionTypes.AiLanguageModel];
}
return mock<LoadedClass<INodeType>>({
type: mock<INodeType>({
// @ts-expect-error
description: mockNodeTypeDescription(config),
}),
});
};
export const mockNodes = [
mockNode({ name: 'Manual Trigger', type: MANUAL_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Set', type: SET_NODE_TYPE }),
mockNode({ name: 'Code', type: CODE_NODE_TYPE }),
mockNode({ name: 'Rename', type: SET_NODE_TYPE }),
mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Form Trigger', type: FORM_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }),
mockNode({ name: 'OpenAI Model', type: OPEN_AI_CHAT_MODEL_NODE_TYPE }),
mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }),
mockNode({ name: 'Simulate', type: SIMULATE_NODE_TYPE }),
mockNode({ name: CanvasNodeRenderType.AddNodes, type: CanvasNodeRenderType.AddNodes }),
mockNode({ name: 'End', type: NO_OP_NODE_TYPE }),
];
export const defaultNodeTypes = mockNodes.reduce<INodeTypeData>((acc, { type }) => {
acc[type] = mockLoadedNodeType(type);
return acc;
}, {});
export const defaultNodeDescriptions = Object.values(defaultNodeTypes).map(
({ type }) => type.description,
) as INodeTypeDescription[];
export function createMockNodeTypes(data: INodeTypeData) {
return mock<INodeTypes>({
getByName(nodeType) {
return data[nodeType].type;
},
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(data[nodeType].type, version);
},
});
}
const nodeTypes = createMockNodeTypes(defaultNodeTypes);
export function createTestWorkflowObject({
id = uuid(),
name = 'Test Workflow',
nodes = [],
connections = {},
active = false,
staticData = {},
settings = {},
pinData = {},
...rest
}: {
id?: string;
name?: string;
nodes?: INode[];
connections?: IConnections;
active?: boolean;
staticData?: IDataObject;
settings?: IWorkflowSettings;
pinData?: IPinData;
nodeTypes?: INodeTypes;
} = {}) {
return new Workflow({
id,
name,
nodes,
connections,
active,
staticData,
settings,
pinData,
nodeTypes: rest.nodeTypes ?? nodeTypes,
});
}
export function createTestWorkflow({
id = uuid(),
name = 'Test Workflow',
nodes = [],
connections = {},
active = false,
isArchived = false,
settings = {
timezone: 'DEFAULT',
executionOrder: 'v1',
},
pinData = {},
...rest
}: Partial<IWorkflowDb> = {}): IWorkflowDb {
return {
createdAt: '',
updatedAt: '',
id,
name,
nodes,
connections,
active,
isArchived,
settings,
versionId: 'v1',
activeVersionId: active ? 'v1' : null,
meta: {},
pinData,
checksum: 'checksum',
...rest,
};
}
export function createTestNode(node: Partial<INode> = {}): INode {
return {
id: uuid(),
name: 'Node',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
...node,
};
}
export function createTestNodeProperties(data: Partial<INodeProperties> = {}): INodeProperties {
return {
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
...data,
};
}
export function createMockEnterpriseSettings(
overrides: Partial<FrontendSettings['enterprise']> = {},
): FrontendSettings['enterprise'] {
return {
sharing: false,
ldap: false,
saml: false,
oidc: false,
provisioning: false,
mfaEnforcement: false,
logStreaming: false,
advancedExecutionFilters: false,
variables: false,
sourceControl: false,
auditLogs: false,
externalSecrets: false,
showNonProdBanner: false,
debugInEditor: false,
binaryDataS3: false,
workerView: false,
advancedPermissions: false,
apiKeyScopes: false,
workflowDiffs: false,
namedVersions: false,
projects: {
team: {
limit: 0,
},
},
customRoles: false,
personalSpacePolicy: false,
...overrides, // Override with any passed properties
};
}
export function createTestTaskData(partialData: Partial<ITaskData> = {}): ITaskData {
return {
startTime: 0,
executionTime: 1,
executionIndex: 0,
source: [],
executionStatus: 'success',
data: { main: [[{ json: {} }]] },
...partialData,
};
}
export function createTestWorkflowExecutionResponse(
data: Partial<IExecutionResponse> = {},
): IExecutionResponse {
return {
id: 'test-exec-id',
finished: true,
mode: 'manual',
status: 'error',
workflowData: createTestWorkflow(),
data: createEmptyRunExecutionData(),
createdAt: '2025-04-16T00:00:00.000Z',
startedAt: '2025-04-16T00:00:01.000Z',
...data,
};
}
export function createTestExpressionLocalResolveContext(
data: Partial<ExpressionLocalResolveContext> = {},
): ExpressionLocalResolveContext {
const workflow = data.workflow ?? createTestWorkflowObject();
return {
localResolve: true,
workflow,
nodeName: 'n0',
inputNode: { name: 'n1', runIndex: 0, branchIndex: 0 },
envVars: {},
additionalKeys: {},
connections: workflow.connectionsBySourceNode,
execution: null,
...data,
};
}
@@ -0,0 +1,81 @@
import type { Plugin } from 'vue';
import { render, type RenderOptions as TestingLibraryRenderOptions } from '@testing-library/vue';
import { i18nInstance } from '@n8n/i18n';
import { GlobalDirectivesPlugin } from '@/app/plugins/directives';
import { N8nPlugin } from '@n8n/design-system';
import type { Pinia } from 'pinia';
import { PiniaVuePlugin } from 'pinia';
import type { Telemetry } from '@/app/plugins/telemetry';
import vueJsonPretty from 'vue-json-pretty';
import merge from 'lodash/merge';
import type { TestingPinia } from '@pinia/testing';
export type RenderOptions<T> = Omit<TestingLibraryRenderOptions<T>, 'props'> & {
pinia?: TestingPinia | Pinia;
props?: Partial<TestingLibraryRenderOptions<T>['props']>;
};
const TelemetryPlugin: Plugin<{}> = {
install(app) {
app.config.globalProperties.$telemetry = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
track(..._: unknown[]) {},
} as Telemetry;
},
};
const defaultOptions = {
global: {
stubs: {
RouterLink: {
template: '<a><slot /></a>',
},
VueJsonPretty: vueJsonPretty,
},
plugins: [i18nInstance, PiniaVuePlugin, N8nPlugin, GlobalDirectivesPlugin, TelemetryPlugin],
},
};
export function renderComponent<T>(component: T, options: RenderOptions<T> = {}) {
const { pinia, ...renderOptions } = options;
return render(component, {
...defaultOptions,
...renderOptions,
global: {
...defaultOptions.global,
...renderOptions.global,
stubs: { ...defaultOptions.global.stubs, ...(renderOptions.global?.stubs ?? {}) },
plugins: [
...defaultOptions.global.plugins,
...(renderOptions.global?.plugins ?? []),
...(pinia ? [pinia] : []),
],
},
} as TestingLibraryRenderOptions<T>);
}
export function createComponentRenderer<T>(component: T, defaultOptions: RenderOptions<T> = {}) {
return (options: RenderOptions<T> = {}, rendererOptions: { merge?: boolean } = {}) =>
renderComponent(
component,
rendererOptions.merge
? merge(defaultOptions, options)
: ({
...defaultOptions,
...options,
props: {
...(defaultOptions.props ?? {}),
...(options.props ?? {}),
},
global: {
...defaultOptions.global,
...options.global,
provide: {
...defaultOptions.global?.provide,
...options.global?.provide,
},
},
} as RenderOptions<T>),
);
}
@@ -0,0 +1,11 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForCredentials(server: Server) {
server.get('/rest/credentials', (schema: AppSchema) => {
const { models: data } = schema.all('credential');
return new Response(200, {}, { data });
});
}
@@ -0,0 +1,11 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForCredentialTypes(server: Server) {
server.get('/types/credentials.json', (schema: AppSchema) => {
const { models: data } = schema.all('credentialType');
return new Response(200, {}, data);
});
}
@@ -0,0 +1,26 @@
import type { Server } from 'miragejs';
import { routesForUsers } from './user';
import { routesForCredentials } from './credential';
import { routesForCredentialTypes } from './credentialType';
import { routesForVariables } from './variable';
import { routesForSettings } from './settings';
import { routesForSSO } from './sso';
import { routesForSourceControl } from './sourceControl';
import { routesForWorkflows } from './workflow';
import { routesForTags } from './tag';
import { routesForModuleSettings } from './module';
const endpoints: Array<(server: Server) => void> = [
routesForCredentials,
routesForCredentialTypes,
routesForUsers,
routesForVariables,
routesForSettings,
routesForSSO,
routesForSourceControl,
routesForWorkflows,
routesForTags,
routesForModuleSettings,
];
export { endpoints };
@@ -0,0 +1,7 @@
import type { Server } from 'miragejs';
export function routesForModuleSettings(server: Server) {
server.get('/rest/module-settings', () => {
return { data: {} };
});
}
@@ -0,0 +1,15 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import { defaultSettings } from '../../defaults';
export function routesForSettings(server: Server) {
server.get('/rest/settings', () => {
return new Response(
200,
{},
{
data: defaultSettings,
},
);
});
}
@@ -0,0 +1,86 @@
import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs';
import { jsonParse } from 'n8n-workflow';
import type { AppSchema } from '@/__tests__/server/types';
import type { SourceControlPreferences } from '@/features/integrations/sourceControl.ee/sourceControl.types';
export function routesForSourceControl(server: Server) {
const sourceControlApiRoot = '/rest/source-control';
const defaultSourceControlPreferences: SourceControlPreferences = {
branchName: '',
branches: [],
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#1d6acb',
connected: false,
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEX+25m',
keyGeneratorType: 'ed25519',
};
server.get(`${sourceControlApiRoot}/preferences`, () => {
return new Response(
200,
{},
{
data: defaultSourceControlPreferences,
},
);
});
server.post(`${sourceControlApiRoot}/preferences`, (_schema: AppSchema, request: Request) => {
const requestBody: Partial<SourceControlPreferences> = jsonParse(request.requestBody);
return new Response(
200,
{},
{
data: {
...defaultSourceControlPreferences,
...requestBody,
},
},
);
});
server.patch(`${sourceControlApiRoot}/preferences`, (_schema: AppSchema, request: Request) => {
const requestBody: Partial<SourceControlPreferences> = jsonParse(request.requestBody);
return new Response(
200,
{},
{
data: {
...defaultSourceControlPreferences,
...requestBody,
},
},
);
});
server.get(`${sourceControlApiRoot}/get-branches`, () => {
return new Response(
200,
{},
{
data: {
branches: ['main', 'dev'],
currentBranch: 'main',
},
},
);
});
server.post(`${sourceControlApiRoot}/disconnect`, () => {
return new Response(
200,
{},
{
data: {
...defaultSourceControlPreferences,
branchName: '',
connected: false,
},
},
);
});
}
@@ -0,0 +1,35 @@
import type { SamlPreferences } from '@n8n/api-types';
import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs';
import type { SamlPreferencesExtractedData } from '@n8n/rest-api-client/api/sso';
import { faker } from '@faker-js/faker';
import type { AppSchema } from '@/__tests__/server/types';
import { jsonParse } from 'n8n-workflow';
let samlConfig = {
metadata: '<?xml version="1.0"?>',
metadataUrl: '',
entityID: faker.internet.url(),
returnUrl: faker.internet.url(),
} as SamlPreferences & SamlPreferencesExtractedData;
export function routesForSSO(server: Server) {
server.get('/rest/sso/saml/config', () => {
return new Response(200, {}, { data: samlConfig });
});
server.post('/rest/sso/saml/config', (_schema: AppSchema, request: Request) => {
const requestBody = jsonParse<object>(request.requestBody);
samlConfig = {
...samlConfig,
...requestBody,
};
return new Response(200, {}, { data: samlConfig });
});
server.get('/rest/sso/saml/config/test', () => {
return new Response(200, {}, { data: '<?xml version="1.0"?>' });
});
}
@@ -0,0 +1,11 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForTags(server: Server) {
server.get('/rest/tags', (schema: AppSchema) => {
const { models: data } = schema.all('tag');
return new Response(200, {}, { data });
});
}
@@ -0,0 +1,19 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForUsers(server: Server) {
server.get('/rest/users', (schema: AppSchema) => {
const { models: data } = schema.all('user');
return new Response(200, {}, { data });
});
server.get('/rest/login', (schema: AppSchema) => {
const model = schema.findBy('user', {
isDefaultUser: true,
});
return new Response(200, {}, { data: model?.attrs });
});
}
@@ -0,0 +1,42 @@
import type { Request, Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
import { jsonParse } from 'n8n-workflow';
import type { EnvironmentVariable } from '@/features/settings/environments.ee/environments.types';
export function routesForVariables(server: Server) {
server.get('/rest/variables', (schema: AppSchema) => {
const { models: data } = schema.all('variable');
return new Response(200, {}, { data });
});
server.post('/rest/variables', (schema: AppSchema, request: Request) => {
const data = schema.create('variable', jsonParse(request.requestBody));
return new Response(200, {}, { data });
});
server.patch('/rest/variables/:id', (schema: AppSchema, request: Request) => {
const data: EnvironmentVariable = jsonParse(request.requestBody);
const id = request.params.id;
const model = schema.find('variable', id);
if (model) {
model.update(data);
}
return new Response(200, {}, { data: model?.attrs });
});
server.delete('/rest/variables/:id', (schema: AppSchema, request: Request) => {
const id = request.params.id;
const model = schema.find('variable', id);
if (model) {
model.destroy();
}
return new Response(200, {}, {});
});
}
@@ -0,0 +1,16 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForWorkflows(server: Server) {
server.get('/rest/workflows', (schema: AppSchema) => {
const { models: data } = schema.all('workflow');
return new Response(200, {}, { data });
});
server.get('/rest/active-workflows', (schema: AppSchema) => {
const { models: data } = schema.all('workflow');
return new Response(200, {}, { data });
});
}
@@ -0,0 +1,24 @@
import { Factory } from 'miragejs';
import { faker } from '@faker-js/faker';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
export const credentialFactory = Factory.extend<ICredentialsResponse>({
id(i: number) {
return `${i}`;
},
createdAt() {
return faker.date.recent().toISOString();
},
name() {
return faker.company.name();
},
type() {
return 'notionApi';
},
updatedAt() {
return '';
},
isManaged() {
return false;
},
});
@@ -0,0 +1,25 @@
import { Factory } from 'miragejs';
import type { ICredentialType } from 'n8n-workflow';
const credentialTypes = [
'airtableApi',
'dropboxApi',
'figmaApi',
'googleApi',
'gitlabApi',
'jenkinsApi',
'metabaseApi',
'notionApi',
];
export const credentialTypeFactory = Factory.extend<ICredentialType>({
name(i) {
return credentialTypes[i];
},
displayName(i) {
return credentialTypes[i];
},
properties() {
return [];
},
});
@@ -0,0 +1,20 @@
import { userFactory } from './user';
import { credentialFactory } from './credential';
import { credentialTypeFactory } from './credentialType';
import { variableFactory } from './variable';
import { workflowFactory } from './workflow';
import { tagFactory } from './tag';
export * from './user';
export * from './credential';
export * from './credentialType';
export * from './variable';
export const factories = {
credential: credentialFactory,
credentialType: credentialTypeFactory,
user: userFactory,
variable: variableFactory,
workflow: workflowFactory,
tag: tagFactory,
};
@@ -0,0 +1,12 @@
import { Factory } from 'miragejs';
import type { ITag } from '@n8n/rest-api-client/api/tags';
import { faker } from '@faker-js/faker';
export const tagFactory = Factory.extend<ITag>({
id(i: number) {
return i.toString();
},
name() {
return faker.lorem.word();
},
});
@@ -0,0 +1,31 @@
import { Factory } from 'miragejs';
import { faker } from '@faker-js/faker';
import { SignInType } from '@/app/constants';
import type { IUser } from '@n8n/rest-api-client/api/users';
export const userFactory = Factory.extend<IUser>({
id(i: number) {
return `${i}`;
},
firstName() {
return faker.name.firstName();
},
lastName() {
return faker.name.lastName();
},
isDefaultUser() {
return false;
},
isPending() {
return false;
},
isPendingUser() {
return false;
},
signInType(): SignInType {
return SignInType.EMAIL;
},
mfaEnabled() {
return false;
},
});
@@ -0,0 +1,15 @@
import { Factory } from 'miragejs';
import { faker } from '@faker-js/faker';
import type { EnvironmentVariable } from '@/features/settings/environments.ee/environments.types';
export const variableFactory = Factory.extend<EnvironmentVariable>({
id(i: number) {
return `${i}`;
},
key() {
return `${faker.lorem.word()}`.toUpperCase();
},
value() {
return faker.internet.password(10);
},
});
@@ -0,0 +1,39 @@
import { Factory } from 'miragejs';
import type { IWorkflowDb } from '@/Interface';
import { faker } from '@faker-js/faker';
export const workflowFactory = Factory.extend<IWorkflowDb>({
id(i: number) {
return i.toString();
},
versionId(i: number) {
return i.toString();
},
active() {
return faker.datatype.boolean();
},
activeVersionId(i: number) {
return this.active ? i.toString() : null;
},
isArchived() {
return faker.datatype.boolean();
},
nodes() {
return [];
},
connections() {
return {};
},
name() {
return faker.lorem.word();
},
createdAt() {
return faker.date.recent().toISOString();
},
updatedAt() {
return faker.date.recent().toISOString();
},
tags() {
return faker.lorem.words(2.5).split(' ');
},
});
@@ -0,0 +1,7 @@
import { tags } from './tags';
import { workflows } from './workflows';
export const fixtures = {
tags,
workflows,
};
@@ -0,0 +1,15 @@
import type { ITag } from '@n8n/rest-api-client/api/tags';
export const tags: ITag[] = [
{
id: '1',
name: 'tag1',
},
{
id: '2',
name: 'tag2',
},
{
id: '3',
name: 'tag3',
},
];
@@ -0,0 +1,47 @@
import type { IWorkflowDb } from '@/Interface';
import { faker } from '@faker-js/faker';
export const workflows = [
{
id: '1',
name: 'workflow1',
tags: [],
},
{
id: '2',
name: 'workflow2',
tags: [
{ id: '1', name: 'tag1' },
{ id: '2', name: 'tag2' },
],
},
{
id: '3',
name: 'workflow3',
tags: [
{ id: '1', name: 'tag1' },
{ id: '3', name: 'tag3' },
],
},
{
id: '4',
name: 'workflow4',
tags: [
{ id: '2', name: 'tag2' },
{ id: '3', name: 'tag3' },
],
},
{
id: '5',
name: 'workflow5',
tags: [
{ id: '1', name: 'tag1' },
{ id: '2', name: 'tag2' },
{ id: '3', name: 'tag3' },
],
},
].map((wf, idx) => ({
...wf,
createdAt: faker.date.recent().toISOString(),
updatedAt: new Date(`2024-1-${idx + 1}`).toISOString(),
})) as IWorkflowDb[];
@@ -0,0 +1,47 @@
import { createServer } from 'miragejs';
import { endpoints } from './endpoints';
import { models } from './models';
import { factories } from './factories';
import { fixtures } from './fixtures';
export function setupServer() {
const server = createServer({
models,
factories,
fixtures,
seeds(server) {
server.loadFixtures('tags', 'workflows');
server.createList('credentialType', 8);
server.create('user', {
firstName: 'Nathan',
lastName: 'Doe',
isDefaultUser: true,
});
},
});
// Set server url prefix
server.urlPrefix = process.env.API_URL || '';
// Enable logging
server.logging = false;
// Handle defined endpoints
for (const endpointsFn of endpoints) {
endpointsFn(server);
}
// Handle undefined endpoints
server.post('/rest/:any', async () => ({}));
// Reset for everything else
server.namespace = '';
server.passthrough();
if (server.logging) {
console.log('Mirage database');
console.log(server.db.dump());
}
return server;
}
@@ -0,0 +1,5 @@
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const CredentialModel: ModelDefinition<ICredentialsResponse> = Model.extend({});
@@ -0,0 +1,5 @@
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
import type { ICredentialType } from 'n8n-workflow';
export const CredentialTypeModel: ModelDefinition<ICredentialType> = Model.extend({});
@@ -0,0 +1,15 @@
import { UserModel } from './user';
import { CredentialModel } from './credential';
import { CredentialTypeModel } from './credentialType';
import { VariableModel } from './variable';
import { WorkflowModel } from './workflow';
import { TagModel } from './tag';
export const models = {
credential: CredentialModel,
credentialType: CredentialTypeModel,
user: UserModel,
variable: VariableModel,
workflow: WorkflowModel,
tag: TagModel,
};
@@ -0,0 +1,5 @@
import type { ITag } from '@n8n/rest-api-client/api/tags';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const TagModel: ModelDefinition<ITag> = Model.extend({});
@@ -0,0 +1,5 @@
import type { IUser } from '@n8n/rest-api-client/api/users';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const UserModel: ModelDefinition<IUser> = Model.extend({});
@@ -0,0 +1,5 @@
import type { EnvironmentVariable } from '@/features/settings/environments.ee/environments.types';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const VariableModel: ModelDefinition<EnvironmentVariable> = Model.extend({});
@@ -0,0 +1,5 @@
import type { IWorkflowDb } from '@/Interface';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const WorkflowModel: ModelDefinition<IWorkflowDb> = Model.extend({});
@@ -0,0 +1,9 @@
import type { Registry } from 'miragejs';
import type Schema from 'miragejs/orm/schema';
import type { models } from './models';
import type { factories } from './factories';
type AppRegistry = Registry<typeof models, typeof factories>;
export type AppSchema = Schema<AppRegistry>;
@@ -0,0 +1,385 @@
import '@testing-library/jest-dom';
import 'fake-indexeddb/auto';
import { configure } from '@testing-library/vue';
import 'core-js/proposals/set-methods-v2';
import englishBaseText from '@n8n/i18n/locales/en.json';
import { loadLanguage, type LocaleMessages } from '@n8n/i18n';
import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
// Global stub for Reka UI Popover components used by N8nPopover.
// Unlike Element+ popover which always renders content regardless of visibility,
// Reka UI respects the `open` prop and only renders content when open.
// This stub implements realistic open/close behavior while rendering inline
// (no teleportation), so tests can interact with popovers naturally.
// - Controlled mode (open prop provided): respects open state
// - Uncontrolled mode (no open prop): clicking trigger toggles visibility
vi.mock('reka-ui', async (importOriginal) => {
const actual = await importOriginal<object>();
const { ref, provide, inject, computed, defineComponent, h } = await import('vue');
const POPOVER_OPEN_KEY = Symbol('popover-open');
return {
...actual,
PopoverRoot: defineComponent({
name: 'PopoverRoot',
props: { open: { type: Boolean, default: undefined } },
emits: ['update:open'],
setup(props, { slots, emit }) {
const internalOpen = ref(false);
// Controlled mode when open prop is explicitly provided
const isControlled = computed(() => props.open !== undefined);
const isOpen = computed(() => (isControlled.value ? props.open : internalOpen.value));
const setOpen = (value: boolean) => {
if (!isControlled.value) {
internalOpen.value = value;
}
emit('update:open', value);
};
provide(POPOVER_OPEN_KEY, { isOpen, setOpen });
return () => h('div', slots.default?.());
},
}),
PopoverTrigger: defineComponent({
name: 'PopoverTrigger',
props: { asChild: Boolean },
setup(_, { slots }) {
const context = inject<{ isOpen: { value: boolean }; setOpen: (v: boolean) => void }>(
POPOVER_OPEN_KEY,
);
return () =>
h('div', { onClick: () => context?.setOpen(!context.isOpen.value) }, slots.default?.());
},
}),
PopoverPortal: defineComponent({
name: 'PopoverPortal',
props: { disabled: Boolean },
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
}),
PopoverContent: defineComponent({
name: 'PopoverContent',
props: ['side', 'sideOffset', 'align', 'class', 'style', 'reference'],
setup(_, { slots, attrs }) {
const context = inject<{ isOpen: { value: boolean } }>(POPOVER_OPEN_KEY);
return () => (context?.isOpen.value ? h('div', attrs, slots.default?.()) : null);
},
}),
PopoverArrow: defineComponent({
name: 'PopoverArrow',
setup() {
return () => h('div');
},
}),
};
});
// Avoid tests failing because of difference between local and GitHub actions timezone
process.env.TZ = 'UTC';
configure({ testIdAttribute: 'data-test-id' });
/**
* PointerEvent polyfill for JSDOM
* Required for Reka UI tooltip hover to work (checks event.pointerType)
*/
class JsonDomPointerEvent extends MouseEvent implements PointerEvent {
readonly pointerId: number;
readonly pointerType: string;
readonly pressure: number;
readonly tangentialPressure: number;
readonly tiltX: number;
readonly tiltY: number;
readonly twist: number;
readonly width: number;
readonly height: number;
readonly isPrimary: boolean;
readonly altitudeAngle: number;
readonly azimuthAngle: number;
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
this.pointerId = params.pointerId ?? 0;
this.pointerType = params.pointerType ?? 'mouse';
this.pressure = params.pressure ?? 0;
this.tangentialPressure = params.tangentialPressure ?? 0;
this.tiltX = params.tiltX ?? 0;
this.tiltY = params.tiltY ?? 0;
this.twist = params.twist ?? 0;
this.width = params.width ?? 1;
this.height = params.height ?? 1;
this.altitudeAngle = params.altitudeAngle ?? Math.PI / 2;
this.azimuthAngle = params.azimuthAngle ?? 0;
this.isPrimary = params.isPrimary ?? true;
}
getCoalescedEvents(): PointerEvent[] {
return [];
}
getPredictedEvents(): PointerEvent[] {
return [];
}
}
// Always apply our PointerEvent polyfill - JSDOM's PointerEvent is incomplete
// and doesn't properly support pointerType which Reka UI requires for tooltips
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).PointerEvent = JsonDomPointerEvent;
/**
* Fixes missing pointer APIs and defaultPrevented issues for jsdom + user-event
* Required for Reka UI components (tooltips, etc.) to work properly in tests
*/
beforeAll(() => {
// Patch missing pointer APIs
const elementProto = HTMLElement.prototype as HTMLElement & {
hasPointerCapture?: (pointerId: number) => boolean;
setPointerCapture?: (pointerId: number) => void;
releasePointerCapture?: (pointerId: number) => void;
};
if (!elementProto.hasPointerCapture) {
Object.defineProperties(elementProto, {
hasPointerCapture: {
value: (_: number) => false,
writable: true,
},
setPointerCapture: {
value: (_: number) => {},
writable: true,
},
releasePointerCapture: {
value: (_: number) => {},
writable: true,
},
});
}
});
// Create DOM containers for Element Plus components before each test
beforeEach(() => {
// Create app-grid container for toasts
const appGrid = document.createElement('div');
appGrid.id = 'app-grid';
document.body.appendChild(appGrid);
// Create app-modals container for modals
const appModals = document.createElement('div');
appModals.id = APP_MODALS_ELEMENT_ID;
document.body.appendChild(appModals);
});
afterEach(() => {
// Clean up only our specific DOM containers to avoid interfering with Vue's unmounting
const appGrid = document.getElementById('app-grid');
const appModals = document.getElementById(APP_MODALS_ELEMENT_ID);
if (appGrid) {
appGrid.remove();
}
if (appModals) {
appModals.remove();
}
});
if (!window.ResizeObserver) {
// Use function constructor instead of class to allow vi.spyOn to work
function MockResizeObserver(this: ResizeObserver, _cb: ResizeObserverCallback) {
this.disconnect = vi.fn();
this.observe = vi.fn();
this.unobserve = vi.fn();
}
window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
}
Element.prototype.scrollIntoView = vi.fn();
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = vi.fn(() => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
}));
export class IntersectionObserver {
root = null;
rootMargin = '';
thresholds = [];
disconnect() {
return null;
}
observe() {
return null;
}
takeRecords() {
return [];
}
unobserve() {
return null;
}
}
window.IntersectionObserver = IntersectionObserver;
global.IntersectionObserver = IntersectionObserver;
// Mocks for useDeviceSupport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn((query) => ({
matches: true,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class Worker {
onmessage = vi.fn();
url: string;
constructor(url: string) {
this.url = url;
}
postMessage = vi.fn((message: string) => {
this.onmessage(message);
});
addEventListener = vi.fn();
terminate = vi.fn();
}
class MockMessagePort {
onmessage = vi.fn();
onmessageerror = vi.fn();
postMessage = vi.fn();
start = vi.fn();
close = vi.fn();
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
class SharedWorker {
port: MockMessagePort;
onerror = vi.fn();
constructor(_url: string | URL, _options?: string | WorkerOptions) {
this.port = new MockMessagePort();
}
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
class DataTransfer {
private data: Record<string, unknown> = {};
setData = vi.fn((type: string, data) => {
this.data[type] = data;
});
getData = vi.fn((type) => {
if (type.startsWith('text')) type = 'text';
return this.data[type] ?? null;
});
}
Object.defineProperty(window, 'Worker', {
writable: true,
value: Worker,
});
Object.defineProperty(window, 'SharedWorker', {
writable: true,
value: SharedWorker,
});
Object.defineProperty(window, 'DataTransfer', {
writable: true,
value: DataTransfer,
});
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
writable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
writable: true,
value: vi.fn(),
});
class SpeechSynthesisUtterance {
text = '';
lang = '';
voice = null;
volume = 1;
rate = 1;
pitch = 1;
onstart = null;
onend = null;
onerror = null;
onpause = null;
onresume = null;
onmark = null;
onboundary = null;
constructor(text?: string) {
if (text) {
this.text = text;
}
}
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
Object.defineProperty(window, 'SpeechSynthesisUtterance', {
writable: true,
value: SpeechSynthesisUtterance,
});
Object.defineProperty(window, 'speechSynthesis', {
writable: true,
value: {
cancel: vi.fn(),
speak: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
getVoices: vi.fn(() => []),
pending: false,
speaking: false,
paused: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
},
});
loadLanguage('en', englishBaseText as LocaleMessages);
@@ -0,0 +1,211 @@
import { within, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { ISettingsState } from '@/Interface';
import { UserManagementAuthenticationMethod } from '@/Interface';
import { defaultSettings } from './defaults';
import type { Mock } from 'vitest';
import type { Store, StoreDefinition } from 'pinia';
import type { ComputedRef } from 'vue';
/**
* Retries the given assertion until it passes or the timeout is reached
*
* @example
* await retry(
* () => expect(screen.getByText('Hello')).toBeInTheDocument()
* );
*/
export const retry = async (assertion: () => void, { interval = 20, timeout = 1000 } = {}) => {
return await new Promise((resolve, reject) => {
const startTime = Date.now();
const tryAgain = () => {
setTimeout(() => {
try {
resolve(assertion());
} catch (error) {
if (Date.now() - startTime > timeout) {
reject(error);
} else {
tryAgain();
}
}
}, interval);
};
tryAgain();
});
};
export const waitAllPromises = async () => await new Promise((resolve) => setTimeout(resolve));
export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
initialized: true,
settings: defaultSettings,
userManagement: {
showSetupOnFirstLoad: false,
smtpSetup: false,
authenticationMethod: UserManagementAuthenticationMethod.Email,
quota: defaultSettings.userManagement.quota,
},
templatesEndpointHealthy: false,
api: {
enabled: false,
latestVersion: 0,
path: '/',
swaggerUi: {
enabled: false,
},
},
ldap: {
loginLabel: '',
loginEnabled: false,
},
saml: {
loginLabel: '',
loginEnabled: false,
},
mfa: {
enabled: false,
},
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveDataProgressExecution: false,
saveManualExecutions: false,
};
export const getDropdownItems = async (dropdownTriggerParent: HTMLElement) => {
await userEvent.click(within(dropdownTriggerParent).getByRole('combobox'));
const selectTrigger = dropdownTriggerParent.querySelector(
'.select-trigger[aria-describedby]',
) as HTMLElement;
await waitFor(() => expect(selectTrigger).toBeInTheDocument());
const selectDropdownId = selectTrigger.getAttribute('aria-describedby');
const selectDropdown = document.getElementById(selectDropdownId as string) as HTMLElement;
await waitFor(() => expect(selectDropdown).toBeInTheDocument());
return selectDropdown.querySelectorAll('.el-select-dropdown__item');
};
export const getSelectedDropdownValue = async (items: NodeListOf<Element>) => {
const selectedItem = Array.from(items).find((item) => item.classList.contains('selected'));
expect(selectedItem).toBeInTheDocument();
return selectedItem?.querySelector('p')?.textContent?.trim();
};
/**
* Typescript helper for mocking pinia store actions return value
*
* @see https://pinia.vuejs.org/cookbook/testing.html#Mocking-the-returned-value-of-an-action
*/
export const mockedStore = <TStoreDef extends () => unknown>(
useStore: TStoreDef,
): TStoreDef extends StoreDefinition<infer Id, infer State, infer Getters, infer Actions>
? Store<
Id,
State,
Record<string, never>,
{
[K in keyof Actions]: Actions[K] extends (...args: infer Args) => infer ReturnT
? Mock<(...args: Args) => ReturnT>
: Actions[K];
}
> & {
[K in keyof Getters]: Getters[K] extends ComputedRef<infer T> ? T : never;
}
: ReturnType<TStoreDef> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return useStore() as any;
};
export type MockedStore<T extends () => unknown> = ReturnType<typeof mockedStore<T>>;
export type Emitter = (event: string, ...args: unknown[]) => void;
export type Emitters<T extends string> = Record<
T,
{
emit: Emitter;
}
>;
export const useEmitters = <T extends string>() => {
const emitters = {} as Emitters<T>;
return {
emitters,
addEmitter: (name: T, emitter: Emitter) => {
emitters[name] = { emit: emitter };
},
};
};
/**
* Helper to get the visible tooltip content container.
* Queries the tooltip by its CSS class which is applied by N8nTooltip component.
* This is the semantic approach since .n8n-tooltip is the design system class.
*
* Usage: const tooltip = getTooltip(); expect(tooltip).toHaveTextContent('...');
*/
export const getTooltip = () => {
const tooltip = document.querySelector('.n8n-tooltip');
if (!tooltip) {
throw new Error('Unable to find tooltip with class .n8n-tooltip');
}
return tooltip as HTMLElement;
};
/**
* Query version that returns null if not found
*/
export const queryTooltip = () => document.querySelector('.n8n-tooltip') as HTMLElement | null;
/**
* Get a within() wrapper for querying inside the tooltip
*/
export const withinTooltip = () => within(getTooltip());
/**
* Triggers tooltip hover by dispatching a proper pointermove event.
* Works with Reka UI tooltips in JSDOM by setting correct pointerType.
*
* Automatically finds the actual tooltip trigger element (with data-grace-area-trigger)
* if the passed element is a parent container.
*
* Requires PointerEvent polyfill in setup.ts (already configured).
*
* @example
* const button = getByRole('button');
* await hoverTooltipTrigger(button);
* await waitFor(() => expect(getTooltip()).toHaveTextContent('Expected text'));
*/
export const hoverTooltipTrigger = async (element: Element): Promise<void> => {
// Find actual tooltip trigger - check element, children, then ancestors
let trigger: Element = element;
if (element.hasAttribute('data-grace-area-trigger')) {
trigger = element;
} else {
// Check children first
const childTrigger = element.querySelector('[data-grace-area-trigger]');
if (childTrigger) {
trigger = childTrigger;
} else {
// Check ancestors
const ancestorTrigger = element.closest('[data-grace-area-trigger]');
if (ancestorTrigger) {
trigger = ancestorTrigger;
}
}
}
const event = new PointerEvent('pointermove', {
bubbles: true,
cancelable: true,
pointerType: 'mouse',
clientX: 100,
clientY: 100,
});
trigger.dispatchEvent(event);
// Allow Vue reactivity and Reka UI to process
await new Promise((r) => setTimeout(r, 10));
};
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import BaseLayout from '@/app/layouts/BaseLayout.vue';
import AppBanners from '@/app/components/app/AppBanners.vue';
import AppModals from '@/app/components/app/AppModals.vue';
import AppCommandBar from '@/app/components/app/AppCommandBar.vue';
import AppLayout from '@/app/components/app/AppLayout.vue';
import AppChatPanel from '@/app/components/app/AppChatPanel.vue';
import { useHistoryHelper } from '@/app/composables/useHistoryHelper';
import { useBackendStatus } from '@/app/composables/useBackendStatus';
import { useTelemetryContext } from '@/app/composables/useTelemetryContext';
import { useTelemetryInitializer } from '@/app/composables/useTelemetryInitializer';
import { useWorkflowDiffRouting } from '@/app/composables/useWorkflowDiffRouting';
import { CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID, HIRING_BANNER, VIEWS } from '@/app/constants';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import LoadingView from '@/app/views/LoadingView.vue';
import { locale } from '@n8n/design-system';
import { setLanguage } from '@n8n/i18n';
// Note: no need to import en.json here; default 'en' is handled via setLanguage
import { useRootStore } from '@n8n/stores/useRootStore';
import axios from 'axios';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStyles } from '@/app/composables/useStyles';
import { useExposeCssVar } from '@/app/composables/useExposeCssVar';
import { useFloatingUiOffsets } from '@/app/composables/useFloatingUiOffsets';
const route = useRoute();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const ndvStore = useNDVStore();
const { setAppZIndexes } = useStyles();
const { toastBottomOffset, askAiFloatingButtonBottomOffset } = useFloatingUiOffsets();
// Initialize undo/redo
useHistoryHelper(route);
// Initialize workflow diff routing management
useWorkflowDiffRouting();
useTelemetryInitializer();
// Initialize global backend status tracking
useBackendStatus();
const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const hasContentFooter = ref(false);
useTelemetryContext({ ndv_source: computed(() => ndvStore.lastSetActiveNodeSource) });
onMounted(async () => {
setAppZIndexes();
logHiringBanner();
loading.value = false;
});
const logHiringBanner = () => {
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
console.log(HIRING_BANNER);
}
};
watch(route, (r) => {
hasContentFooter.value = r.matched.some(
(matchedRoute) => matchedRoute.components?.footer !== undefined,
);
});
watch(
defaultLocale,
async (newLocale) => {
setLanguage(newLocale);
axios.defaults.headers.common['Accept-Language'] = newLocale;
void locale.use(newLocale);
},
{ immediate: true },
);
const layoutRef = ref<Element | null>(null);
const setLayoutRef = (el: Element) => {
layoutRef.value = el;
};
useExposeCssVar('--toast--offset', toastBottomOffset);
useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloatingButtonBottomOffset);
</script>
<template>
<LoadingView v-if="loading" />
<BaseLayout v-else id="n8n-app" :class="$style.app">
<template #banners>
<AppBanners />
</template>
<AppLayout @mounted="setLayoutRef">
<RouterView />
</AppLayout>
<AppModals />
<AppCommandBar />
<div :id="CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID" />
<template #aside>
<AppChatPanel v-if="layoutRef" :layout-ref="layoutRef" />
</template>
</BaseLayout>
</template>
<style lang="scss" module>
// On the root level, whole app is a flex container
// with app grid and assistant sidebar as children
.app {
height: 100vh;
overflow: hidden;
}
</style>
@@ -0,0 +1,20 @@
import type { IUser } from '@n8n/rest-api-client/api/users';
import { post } from '@n8n/rest-api-client';
const N8N_API_BASE_URL = 'https://api.n8n.io/api';
const CONTACT_EMAIL_SUBMISSION_ENDPOINT = '/accounts/onboarding';
export async function submitEmailOnSignup(
instanceId: string,
currentUser: IUser,
email: string | undefined,
agree: boolean,
): Promise<string> {
return await post(N8N_API_BASE_URL, CONTACT_EMAIL_SUBMISSION_ENDPOINT, {
instance_id: instanceId,
user_id: `${instanceId}#${currentUser.id}`,
email,
agree,
agree_updates: true,
});
}
@@ -0,0 +1,46 @@
import type { TransferWorkflowBodyDto } from '@n8n/api-types';
import type { IShareWorkflowsPayload, IWorkflowsShareResponse } from '@/Interface';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IDataObject } from 'n8n-workflow';
export async function setWorkflowSharedWith(
context: IRestApiContext,
id: string,
data: IShareWorkflowsPayload,
): Promise<IWorkflowsShareResponse> {
return await makeRestApiRequest(
context,
'PUT',
`/workflows/${id}/share`,
data as unknown as IDataObject,
);
}
export async function moveWorkflowToProject(
context: IRestApiContext,
id: string,
body: TransferWorkflowBodyDto,
): Promise<void> {
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, body);
}
export async function moveFolderToProject(
context: IRestApiContext,
projectId: string,
folderId: string,
destinationProjectId: string,
destinationParentFolderId?: string,
shareCredentials?: string[],
): Promise<void> {
return await makeRestApiRequest(
context,
'PUT',
`/projects/${projectId}/folders/${folderId}/transfer`,
{
destinationProjectId,
destinationParentFolderId: destinationParentFolderId ?? '0',
shareCredentials,
},
);
}
@@ -0,0 +1,66 @@
import { getLastSuccessfulExecution } from './workflows';
import * as apiUtils from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { vi, describe, it, beforeEach, afterEach, expect } from 'vitest';
import type { MockInstance } from 'vitest';
vi.mock('@n8n/rest-api-client');
describe('API: workflows', () => {
describe('getLastSuccessfulExecution', () => {
let mockContext: IRestApiContext;
let makeRestApiRequestSpy: MockInstance;
beforeEach(() => {
mockContext = {
baseUrl: 'http://test-base-url',
sessionId: 'test-session',
pushRef: 'test-ref',
} as IRestApiContext;
makeRestApiRequestSpy = vi.spyOn(apiUtils, 'makeRestApiRequest');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should call makeRestApiRequest with correct parameters and return execution data', async () => {
const workflowId = 'workflow-123';
const mockResponse = {
id: 'execution-456',
workflowId,
mode: 'trigger',
startedAt: new Date('2025-01-15T10:00:00Z'),
stoppedAt: new Date('2025-01-15T10:05:00Z'),
status: 'success',
finished: true,
};
makeRestApiRequestSpy.mockResolvedValue(mockResponse);
const result = await getLastSuccessfulExecution(mockContext, workflowId);
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(
mockContext,
'GET',
`/workflows/${workflowId}/executions/last-successful`,
);
expect(result).toEqual(mockResponse);
});
it('should return null when no successful execution exists', async () => {
const workflowId = 'workflow-no-success';
makeRestApiRequestSpy.mockResolvedValue(null);
const result = await getLastSuccessfulExecution(mockContext, workflowId);
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(
mockContext,
'GET',
`/workflows/${workflowId}/executions/last-successful`,
);
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,155 @@
import type {
IWorkflowDb,
NewWorkflowResponse,
WorkflowListResource,
WorkflowResource,
} from '@/Interface';
import type {
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
} from '@/features/execution/executions/executions.types';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type {
ExecutionFilters,
ExecutionOptions,
ExecutionSummary,
IDataObject,
} from 'n8n-workflow';
import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client';
export async function getNewWorkflow(context: IRestApiContext, data?: IDataObject) {
const response = await makeRestApiRequest<NewWorkflowResponse>(
context,
'GET',
'/workflows/new',
data,
);
return {
name: response.name,
settings: response.defaultSettings,
};
}
export async function getWorkflow(context: IRestApiContext, id: string) {
return await makeRestApiRequest<IWorkflowDb>(context, 'GET', `/workflows/${id}`);
}
export async function workflowExists(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ exists: boolean }>(context, 'GET', `/workflows/${id}/exists`);
}
export async function getWorkflows(
context: IRestApiContext,
filter?: object,
options?: object,
select?: string[],
) {
return await getFullApiResponse<IWorkflowDb[]>(context, 'GET', '/workflows', {
includeScopes: true,
...(filter ? { filter } : {}),
...(options ? options : {}),
...(select ? { select: JSON.stringify(select) } : {}),
});
}
export async function getWorkflowsWithNodesIncluded(context: IRestApiContext, nodeTypes: string[]) {
return await getFullApiResponse<WorkflowResource[]>(
context,
'POST',
'/workflows/with-node-types',
{
nodeTypes,
},
);
}
export async function getWorkflowsAndFolders(
context: IRestApiContext,
filter?: object,
options?: object,
includeFolders?: boolean,
onlySharedWithMe?: boolean,
) {
return await getFullApiResponse<WorkflowListResource[]>(context, 'GET', '/workflows', {
includeScopes: true,
includeFolders,
onlySharedWithMe,
...(filter ? { filter } : {}),
...(options ? options : {}),
});
}
export async function getActiveWorkflows(context: IRestApiContext) {
return await makeRestApiRequest<string[]>(context, 'GET', '/active-workflows');
}
export async function getActiveExecutions(context: IRestApiContext, filter: IDataObject) {
const output = await makeRestApiRequest<{
results: ExecutionSummary[];
count: number;
estimated: boolean;
}>(context, 'GET', '/executions', { filter });
return output.results;
}
export async function getExecutions(
context: IRestApiContext,
filter?: ExecutionFilters,
options?: ExecutionOptions,
): Promise<{ count: number; results: IExecutionsCurrentSummaryExtended[]; estimated: boolean }> {
return await makeRestApiRequest(context, 'GET', '/executions', { filter, ...options });
}
export async function getExecutionData(context: IRestApiContext, executionId: string) {
return await makeRestApiRequest<IExecutionResponse | null>(
context,
'GET',
`/executions/${executionId}`,
);
}
export async function getLastSuccessfulExecution(
context: IRestApiContext,
workflowId: string,
): Promise<IExecutionResponse | null> {
return await makeRestApiRequest<IExecutionResponse | null>(
context,
'GET',
`/workflows/${workflowId}/executions/last-successful`,
);
}
export async function getWorkflowWriteLock(context: IRestApiContext, workflowId: string) {
return await makeRestApiRequest<{ clientId: string; userId: string } | null>(
context,
'GET',
`/workflows/${workflowId}/collaboration/write-lock`,
);
}
export async function activateWorkflow(
context: IRestApiContext,
workflowId: string,
data: { versionId: string; name?: string; description?: string },
): Promise<IWorkflowDb> {
return await makeRestApiRequest<IWorkflowDb>(
context,
'POST',
`/workflows/${workflowId}/activate`,
data,
);
}
export async function deactivateWorkflow(
context: IRestApiContext,
workflowId: string,
expectedChecksum?: string,
): Promise<IWorkflowDb> {
return await makeRestApiRequest<IWorkflowDb>(
context,
'POST',
`/workflows/${workflowId}/deactivate`,
{ expectedChecksum },
);
}
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { createEventBus } from '@n8n/utils/event-bus';
import Modal from './Modal.vue';
import { ABOUT_MODAL_KEY } from '../constants';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@/app/composables/useToast';
import { useClipboard } from '@/app/composables/useClipboard';
import { useDebugInfo } from '@/app/composables/useDebugInfo';
import { useI18n } from '@n8n/i18n';
import { getThirdPartyLicenses } from '@n8n/rest-api-client';
import { ElCol, ElRow } from 'element-plus';
import { N8nButton, N8nLink, N8nText } from '@n8n/design-system';
const modalBus = createEventBus();
const toast = useToast();
const i18n = useI18n();
const debugInfo = useDebugInfo();
const clipboard = useClipboard();
const rootStore = useRootStore();
const closeDialog = () => {
modalBus.emit('close');
};
const downloadThirdPartyLicenses = async () => {
try {
const thirdPartyLicenses = await getThirdPartyLicenses(rootStore.restApiContext);
const blob = new File([thirdPartyLicenses], 'THIRD_PARTY_LICENSES.md', {
type: 'text/markdown',
});
window.open(URL.createObjectURL(blob));
} catch (error) {
toast.showToast({
title: i18n.baseText('about.thirdPartyLicenses.downloadError'),
message: error.message,
type: 'error',
});
}
};
const copyDebugInfoToClipboard = async () => {
toast.showToast({
title: i18n.baseText('about.debug.toast.title'),
message: i18n.baseText('about.debug.toast.message'),
type: 'info',
duration: 5000,
});
await clipboard.copy(debugInfo.generateDebugInfo());
};
</script>
<template>
<Modal
max-width="540px"
:title="i18n.baseText('about.aboutN8n')"
:event-bus="modalBus"
:name="ABOUT_MODAL_KEY"
:center="true"
>
<template #content>
<div :class="$style.container">
<ElRow>
<ElCol :span="8" class="info-name">
<N8nText>{{ i18n.baseText('about.n8nVersion') }}</N8nText>
</ElCol>
<ElCol :span="16">
<N8nText>{{ rootStore.versionCli }}</N8nText>
</ElCol>
</ElRow>
<ElRow>
<ElCol :span="8" class="info-name">
<N8nText>{{ i18n.baseText('about.sourceCode') }}</N8nText>
</ElCol>
<ElCol :span="16">
<N8nLink to="https://github.com/n8n-io/n8n">https://github.com/n8n-io/n8n</N8nLink>
</ElCol>
</ElRow>
<ElRow>
<ElCol :span="8" class="info-name">
<N8nText>{{ i18n.baseText('about.license') }}</N8nText>
</ElCol>
<ElCol :span="16">
<N8nLink to="https://github.com/n8n-io/n8n/blob/master/LICENSE.md">
{{ i18n.baseText('about.n8nLicense') }}
</N8nLink>
</ElCol>
</ElRow>
<ElRow>
<ElCol :span="8" class="info-name">
<N8nText>{{ i18n.baseText('about.thirdPartyLicenses') }}</N8nText>
</ElCol>
<ElCol :span="16">
<N8nLink @click="downloadThirdPartyLicenses">
{{ i18n.baseText('about.thirdPartyLicensesLink') }}
</N8nLink>
</ElCol>
</ElRow>
<ElRow>
<ElCol :span="8" class="info-name">
<N8nText>{{ i18n.baseText('about.instanceID') }}</N8nText>
</ElCol>
<ElCol :span="16">
<N8nText>{{ rootStore.instanceId }}</N8nText>
</ElCol>
</ElRow>
<ElRow>
<ElCol :span="8" class="info-name">
<N8nText>{{ i18n.baseText('about.debug.title') }}</N8nText>
</ElCol>
<ElCol :span="16">
<div :class="$style.debugInfo" @click="copyDebugInfoToClipboard">
<N8nLink>{{ i18n.baseText('about.debug.message') }}</N8nLink>
</div>
</ElCol>
</ElRow>
</div>
</template>
<template #footer>
<div class="action-buttons">
<N8nButton
float="right"
:label="i18n.baseText('about.close')"
data-test-id="close-about-modal-button"
@click="closeDialog"
/>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.container > * {
margin-bottom: var(--spacing--sm);
overflow-wrap: break-word;
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import Modal from '@/app/components/Modal.vue';
import { useStorage } from '@/app/composables/useStorage';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { getActivatableTriggerNodes, getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils';
import { useExecutionsStore } from '@/features/execution/executions/executions.store';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import {
LOCAL_STORAGE_ACTIVATION_FLAG,
VIEWS,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
} from '../constants';
import { N8nButton, N8nCheckbox, N8nText } from '@n8n/design-system';
const checked = ref(false);
const executionsStore = useExecutionsStore();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const router = useRouter();
const i18n = useI18n();
const modalTitle = computed(() => i18n.baseText('activationModal.workflowPublished'));
const triggerContent = computed(() => {
const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes);
if (!foundTriggers.length) {
return '';
}
if (foundTriggers.length > 1) {
return i18n.baseText('activationModal.yourTriggersWillNowFire');
}
const trigger = foundTriggers[0];
const triggerNodeType = nodeTypesStore.getNodeType(trigger.type, trigger.typeVersion);
if (triggerNodeType) {
if (triggerNodeType.activationMessage) {
return triggerNodeType.activationMessage;
}
const serviceName = getTriggerNodeServiceName(triggerNodeType);
if (trigger.webhookId) {
return i18n.baseText('activationModal.yourWorkflowWillNowListenForEvents', {
interpolate: {
serviceName,
},
});
} else if (triggerNodeType.polling) {
return i18n.baseText('activationModal.yourWorkflowWillNowRegularlyCheck', {
interpolate: {
serviceName,
},
});
}
}
return i18n.baseText('activationModal.yourTriggerWillNowFire');
});
const showExecutionsList = async () => {
const activeExecution = executionsStore.activeExecution;
const currentWorkflow = workflowsStore.workflowId;
if (activeExecution) {
router
.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: activeExecution.id },
})
.catch(() => {});
} else {
router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }).catch(() => {});
}
uiStore.closeModal(WORKFLOW_ACTIVE_MODAL_KEY);
};
const showSettings = async () => {
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
};
const handleCheckboxChange = (checkboxValue: string | number | boolean) => {
const boolValue = typeof checkboxValue === 'boolean' ? checkboxValue : Boolean(checkboxValue);
checked.value = boolValue;
useStorage(LOCAL_STORAGE_ACTIVATION_FLAG).value = boolValue.toString();
};
</script>
<template>
<Modal :name="WORKFLOW_ACTIVE_MODAL_KEY" :title="modalTitle" width="460px">
<template #content>
<div>
<N8nText>{{ triggerContent }}</N8nText>
</div>
<div :class="$style.spaced">
<N8nText>
<N8nText :bold="true">
{{ i18n.baseText('activationModal.theseExecutionsWillNotShowUp') }}
</N8nText>
{{ i18n.baseText('activationModal.butYouCanSeeThem') }}
<a @click="showExecutionsList">
{{ i18n.baseText('activationModal.executionList') }}
</a>
{{ i18n.baseText('activationModal.ifYouChooseTo') }}
<a @click="showSettings">{{ i18n.baseText('activationModal.saveExecutions') }}</a>
</N8nText>
</div>
</template>
<template #footer="{ close }">
<div :class="$style.footer">
<N8nCheckbox :model-value="checked" @update:model-value="handleCheckboxChange">
<template #label>{{ i18n.baseText('generic.dontShowAgain') }}</template>
</N8nCheckbox>
<N8nButton :label="i18n.baseText('activationModal.gotIt')" @click="close" />
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.spaced {
margin-top: var(--spacing--2xs);
}
.footer {
display: flex;
justify-content: flex-end;
align-items: center;
> button {
margin-left: auto;
}
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: 'mini' | 'small' | 'medium' | 'large';
}>(),
{
size: 'medium',
},
);
const sizes = {
mini: 8,
small: 10,
medium: 12,
large: 16,
};
</script>
<template>
<svg
:width="sizes[size]"
:height="sizes[size]"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="NodeIcon">
<path
id="Union"
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.7982 7.80784L13.92 7.20243C12.7186 6.80602 12.0148 5.83386 11.6844 4.61226L10.8579 0.586096C10.8363 0.506544 10.7837 0.400024 10.6219 0.400024C10.4857 0.400024 10.4075 0.506544 10.386 0.586096L9.55943 4.61361C9.22773 5.83521 8.52525 6.80737 7.32387 7.20378L5.44562 7.80919C5.18 7.89548 5.17595 8.27032 5.44023 8.36066L7.33196 9.01191C8.52929 9.40968 9.22773 10.3805 9.55943 11.5967L10.3873 15.5784C10.4089 15.6579 10.4534 15.8008 10.6233 15.8008C10.7991 15.8008 10.8362 15.6634 10.858 15.5831L10.8592 15.5784L11.6871 11.5967C12.0188 10.3791 12.7173 9.40833 13.9146 9.01191L15.8063 8.36066C16.0679 8.26897 16.0639 7.89413 15.7982 7.80784ZM5.04114 11.3108C3.92815 10.9434 3.81743 10.5296 3.63184 9.83597L3.62672 9.81687L3.15615 8.16649C3.12784 8.05997 2.85008 8.05997 2.82041 8.16649L2.50085 9.69147C2.31074 10.394 1.90623 10.9522 1.21588 11.18L0.11563 11.6574C-0.0367335 11.7072 -0.0394302 11.923 0.112933 11.9742L1.22127 12.3666C1.90893 12.5945 2.31074 13.1527 2.5022 13.8525L2.82176 15.3114C2.85142 15.4179 3.12784 15.4179 3.15615 15.3114L3.53099 13.8592C3.72111 13.1554 4.01235 12.5958 4.94675 12.3666L5.98768 11.9742C6.14004 11.9216 6.13869 11.7059 5.98498 11.656L5.04114 11.3108ZM5.33019 0.812949C5.36674 0.661849 5.58158 0.659434 5.61894 0.811355L5.61899 0.811582L6.02856 2.50239C6.08442 2.69624 6.23624 2.8465 6.43132 2.89951L7.47286 3.18013C7.61383 3.2197 7.61829 3.41714 7.48035 3.46394L7.48015 3.46401L6.38799 3.83076C6.21241 3.88968 6.07619 4.03027 6.02153 4.20719L5.61894 5.77311L5.61884 5.77349C5.58095 5.92613 5.36829 5.91987 5.33166 5.77336L4.94237 4.21215C4.88888 4.03513 4.75378 3.89336 4.57956 3.83328L3.48805 3.4555C3.34919 3.40591 3.36033 3.20859 3.50031 3.17175L3.50054 3.17169L4.53472 2.90337C4.73486 2.85153 4.89134 2.69755 4.94463 2.49805L5.33019 0.812949Z"
fill="currentColor"
/>
</g>
</svg>
</template>
@@ -0,0 +1,16 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
const i18n = useI18n();
defineProps<{
nodeName: string;
}>();
</script>
<template>
<div>
{{ i18n.baseText('aiAssistant.codeUpdated.message.body1') }}
<a data-action="openNodeDetail" :data-action-parameter-node="nodeName">{{ nodeName }}</a>
{{ i18n.baseText('aiAssistant.codeUpdated.message.body2') }}
</div>
</template>
@@ -0,0 +1,86 @@
<template>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<path d="M7 0L7 3" stroke="currentColor" stroke-width="2" class="line one" />
<path
d="M11.9497 2.05031L9.82837 4.17163"
stroke="currentColor"
stroke-width="2"
class="line two"
/>
<path d="M14 7L11 7" stroke="currentColor" stroke-width="2" class="line three" />
<path
d="M11.9497 11.9497L9.82839 9.82839"
stroke="currentColor"
stroke-width="2"
class="line four"
/>
<path d="M7 14L7 11" stroke="currentColor" stroke-width="2" class="line five" />
<path d="M0 7L3 7" stroke="currentColor" stroke-width="2" class="line seven" />
<path
d="M2.05031 2.05031L4.17163 4.17163"
stroke="currentColor"
stroke-width="2"
class="line eight"
/>
<path
d="M2.05029 11.9497L4.17161 9.82839"
stroke="currentColor"
stroke-width="2"
class="line six"
/>
</g>
</svg>
</template>
<style scoped>
@keyframes fade {
0% {
opacity: 100;
}
20% {
opacity: 0;
}
40% {
opacity: 100;
}
}
.line {
animation: fade 1.6s infinite;
}
.one {
animation-delay: 0s;
}
.two {
animation-delay: 0.2s;
}
.three {
animation-delay: 0.4s;
}
.four {
animation-delay: 0.6s;
}
.five {
animation-delay: 0.8s;
}
.six {
animation-delay: 1s;
}
.seven {
animation-delay: 1.2s;
}
.eight {
animation-delay: 1.4s;
}
</style>
@@ -0,0 +1,54 @@
<script lang="ts">
import { ElTag } from 'element-plus';
export default {
components: { ElTag },
props: ['text', 'type'],
};
</script>
<template>
<ElTag
v-if="type === 'danger'"
type="danger"
size="small"
:class="$style['danger']"
:disable-transitions="true"
>
{{ text }}
</ElTag>
<ElTag
v-else-if="type === 'warning'"
size="small"
:class="$style['warning']"
:disable-transitions="true"
>
{{ text }}
</ElTag>
</template>
<style lang="scss" module>
.badge {
font-size: 11px;
line-height: 18px;
max-height: 18px;
font-weight: var(--font-weight--regular);
display: flex;
align-items: center;
padding: 2px 4px;
}
.danger {
composes: badge;
color: $badge-danger-color;
background-color: $badge-danger-background-color;
border-color: $badge-danger-border-color;
}
.warning {
composes: badge;
background-color: $badge-warning-background-color;
color: $badge-warning-color;
border: none;
}
</style>
@@ -0,0 +1,134 @@
<script setup lang="ts">
/**
* @TODO Move to design system
*/
import { ref } from 'vue';
import { ElTag } from 'element-plus';
import { N8nButton, N8nIcon, N8nLink } from '@n8n/design-system';
interface Props {
theme: 'success' | 'danger';
message: string;
buttonLabel?: string;
buttonLoadingLabel?: string;
buttonTitle?: string;
details?: string;
buttonLoading?: boolean;
}
withDefaults(defineProps<Props>(), {
buttonLoading: false,
buttonLabel: '',
buttonLoadingLabel: '',
buttonTitle: '',
details: '',
});
const emit = defineEmits<{
click: [];
}>();
const expanded = ref(false);
const expand = () => {
expanded.value = true;
};
const onClick = () => {
expanded.value = false;
emit('click');
};
</script>
<template>
<ElTag :type="theme" :disable-transitions="true" :class="$style.container">
<N8nIcon
:icon="theme === 'success' ? 'circle-check' : 'triangle-alert'"
:class="theme === 'success' ? $style.icon : $style.dangerIcon"
/>
<div :class="$style.banner">
<div :class="$style.content">
<div>
<span :class="theme === 'success' ? $style.message : $style.dangerMessage">
{{ message }}&nbsp;
</span>
<N8nLink v-if="details && !expanded" :bold="true" size="small" @click="expand">
<span :class="$style.moreDetails">More details</span>
</N8nLink>
</div>
</div>
<slot v-if="$slots.button" name="button" />
<N8nButton
variant="outline"
v-else-if="buttonLabel"
:label="buttonLoading && buttonLoadingLabel ? buttonLoadingLabel : buttonLabel"
:title="buttonTitle"
:loading="buttonLoading"
size="small"
@click.stop="onClick"
/>
</div>
<div v-if="expanded" :class="$style.details">
{{ details }}
</div>
</ElTag>
</template>
<style module lang="scss">
.icon {
position: absolute;
left: 14px;
top: 0;
bottom: 0;
margin: auto 0;
}
.dangerIcon {
composes: icon;
color: var(--color--danger);
}
.container {
width: 100%;
position: relative;
padding-left: 40px;
border: none;
}
.message {
white-space: normal;
line-height: var(--line-height--md);
overflow: hidden;
word-break: break-word;
}
.dangerMessage {
composes: message;
color: var(--callout--color--text--danger);
}
.banner {
display: flex;
align-items: center;
}
.content {
flex-grow: 1;
min-height: 26px;
display: flex;
align-items: center;
}
.details {
composes: message;
margin-top: var(--spacing--3xs);
color: var(--color--text);
font-size: var(--font-size--2xs);
}
.moreDetails {
font-size: var(--font-size--xs);
}
</style>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
N8nMenuItem,
N8nPopover,
N8nText,
isCustomMenuItem,
type IMenuItem,
type IMenuElement,
} from '@n8n/design-system';
import { RELEASE_NOTES_URL } from '@/app/constants';
import { useVersionsStore } from '@/app/stores/versions.store';
import VersionUpdateCTA from '@/app/components/VersionUpdateCTA.vue';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useI18n } from '@n8n/i18n';
defineProps<{
items: IMenuItem[];
isCollapsed: boolean;
}>();
const emit = defineEmits<{
select: [key: string];
logout: [];
}>();
const versionsStore = useVersionsStore();
const usersStore = useUsersStore();
const i18n = useI18n();
const whatsNewItems = computed<{ available: boolean; children: IMenuElement[] }>(() => ({
available: versionsStore.hasVersionUpdates || versionsStore.whatsNewArticles.length > 0,
children: [
...versionsStore.whatsNewArticles.map(
(article) =>
({
id: `whats-new-article-${article.id}`,
label: article.title,
size: 'small',
customIconSize: 'small',
icon: {
type: 'emoji',
value: '•',
color: !versionsStore.isWhatsNewArticleRead(article.id) ? 'primary' : 'text-light',
},
}) satisfies IMenuItem,
),
{
id: 'full-changelog',
icon: 'external-link',
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
link: {
href: RELEASE_NOTES_URL,
target: '_blank',
},
size: 'small',
customIconSize: 'small',
},
...(versionsStore.hasVersionUpdates
? [
{
id: 'version-upgrade-cta',
component: VersionUpdateCTA,
props: {
tooltipText: !usersStore.canUserUpdateVersion
? i18n.baseText('whatsNew.updateNudgeTooltip')
: undefined,
disabled: !usersStore.canUserUpdateVersion,
},
},
]
: []),
],
}));
function handleSelect(key: string) {
emit('select', key);
}
function onLogout() {
emit('logout');
}
</script>
<template>
<div
:class="{
[$style.bottomMenu]: true,
[$style.collapsed]: isCollapsed,
}"
>
<div :class="$style.bottomMenuItems">
<template v-for="item in items" :key="item.id">
<!-- Help popover -->
<N8nPopover
v-if="item.children && item.id === 'help'"
key="help"
side="right"
align="end"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<template v-if="whatsNewItems.available">
<N8nText bold size="small" :class="$style.popoverTitle" color="text-light"
>What's new</N8nText
>
<template v-for="child in whatsNewItems.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
</template>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopover>
<!-- Settings popover -->
<N8nPopover
v-else-if="item.children && item.id === 'settings'"
key="settings"
side="right"
align="end"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<span :class="$style.divider" />
<N8nMenuItem
:data-test-id="'main-sidebar-log-out'"
:item="{ id: 'sign-out', label: 'Sign out', icon: 'door-open' }"
@click="onLogout"
/>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopover>
<!-- Items without children -->
<N8nMenuItem
v-else
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</div>
</div>
</template>
<style lang="scss" module>
.bottomMenu {
display: flex;
flex-direction: column;
margin-top: auto;
&.collapsed {
border-top: var(--border);
}
}
.bottomMenuItems {
padding: var(--spacing--3xs);
}
.popover {
padding: var(--spacing--4xs);
min-width: 260px;
border-radius: var(--radius);
background-color: var(--menu--color--background, var(--color--background--light-2));
}
.popoverTitle {
display: block;
margin-bottom: var(--spacing--3xs);
padding-left: var(--spacing--3xs);
margin-top: var(--spacing--xs);
}
.divider {
display: block;
width: 100%;
padding-top: var(--spacing--3xs);
border-bottom: var(--border);
margin-bottom: var(--spacing--3xs);
background-color: var(--color--border);
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import {
BREAKPOINT_SM,
BREAKPOINT_MD,
BREAKPOINT_LG,
BREAKPOINT_XL,
} from '@/app/constants/breakpoints';
import { useBannersStore } from '@/features/shared/banners/banners.store';
import { getBannerRowHeight } from '@/app/utils/htmlUtils';
import { useDebounce } from '@/app/composables/useDebounce';
/**
* matching element.io https://element.eleme.io/#/en-US/component/layout#col-attributes
* xs < 768
* sm >= 768
* md >= 992
* lg >= 1200
* xl >= 1920
*/
interface Props {
valueXS?: number;
valueXL?: number;
valueLG?: number;
valueMD?: number;
valueSM?: number;
valueDefault?: number;
}
const props = defineProps<Props>();
const { callDebounced } = useDebounce();
const bannersStore = useBannersStore();
const width = ref(window.innerWidth);
const bp = computed(() => {
if (width.value < BREAKPOINT_SM) {
return 'XS';
}
if (width.value >= BREAKPOINT_XL) {
return 'XL';
}
if (width.value >= BREAKPOINT_LG) {
return 'LG';
}
if (width.value >= BREAKPOINT_MD) {
return 'MD';
}
return 'SM';
});
const value = computed(() => {
if (props.valueXS && width.value < BREAKPOINT_SM) {
return props.valueXS;
}
if (props.valueXL && width.value >= BREAKPOINT_XL) {
return props.valueXL;
}
if (props.valueLG && width.value >= BREAKPOINT_LG) {
return props.valueLG;
}
if (props.valueMD && width.value >= BREAKPOINT_MD) {
return props.valueMD;
}
if (props.valueSM) {
return props.valueSM;
}
return props.valueDefault;
});
const onResize = () => {
void callDebounced(onResizeEnd, { debounceTime: 50 });
};
const onResizeEnd = async () => {
width.value = window.innerWidth;
await nextTick();
const bannerHeight = await getBannerRowHeight();
bannersStore.updateBannersHeight(bannerHeight);
};
onMounted(() => {
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
</script>
<template>
<span>
<slot :bp="bp" :value="value" />
</span>
</template>
@@ -0,0 +1,41 @@
import ChatEmbedModal from '@/app/components/ChatEmbedModal.vue';
import { createTestingPinia } from '@pinia/testing';
import { CHAT_EMBED_MODAL_KEY, WEBHOOK_NODE_TYPE } from '@/app/constants';
import { STORES } from '@n8n/stores';
import { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
const renderComponent = createComponentRenderer(ChatEmbedModal, {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
[CHAT_EMBED_MODAL_KEY]: { open: true },
},
},
[STORES.WORKFLOWS]: {
workflow: {
nodes: [{ type: WEBHOOK_NODE_TYPE }],
},
},
},
}),
});
describe('ChatEmbedModal', () => {
it('should render correctly', async () => {
const { getByTestId } = renderComponent();
await waitFor(() => expect(getByTestId('chatEmbed-modal')).toBeInTheDocument());
const modalContainer = getByTestId('chatEmbed-modal');
const tabs = modalContainer.querySelectorAll('.n8n-tabs .tab');
const activeTab = modalContainer.querySelector('.n8n-tabs .tab.activeTab');
const editor = modalContainer.querySelector('.cm-editor');
expect(tabs).toHaveLength(4);
expect(activeTab).toBeVisible();
expect(activeTab).toHaveTextContent('CDN Embed');
expect(editor).toBeVisible();
});
});
@@ -0,0 +1,205 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import Modal from './Modal.vue';
import { CHAT_EMBED_MODAL_KEY, CHAT_TRIGGER_NODE_TYPE, WEBHOOK_NODE_TYPE } from '../constants';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import HtmlEditor from '@/features/shared/editors/components/HtmlEditor/HtmlEditor.vue';
import JsEditor from '@/features/shared/editors/components/JsEditor/JsEditor.vue';
import { useI18n } from '@n8n/i18n';
import { I18nT } from 'vue-i18n';
import { N8nButton, N8nInfoTip, N8nLink, N8nTabs, N8nText } from '@n8n/design-system';
const props = withDefaults(
defineProps<{
modalBus?: EventBus;
}>(),
{
modalBus: () => createEventBus(),
},
);
const i18n = useI18n();
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
type ChatEmbedModalTabValue = 'cdn' | 'vue' | 'react' | 'other';
type ChatEmbedModalTab = {
label: string;
value: ChatEmbedModalTabValue;
};
const tabs = ref<ChatEmbedModalTab[]>([
{
label: 'CDN Embed',
value: 'cdn',
},
{
label: 'Vue Embed',
value: 'vue',
},
{
label: 'React Embed',
value: 'react',
},
{
label: 'Other',
value: 'other',
},
]);
const currentTab = ref<ChatEmbedModalTabValue>('cdn');
const webhookNode = computed(() => {
for (const type of [CHAT_TRIGGER_NODE_TYPE, WEBHOOK_NODE_TYPE]) {
const node = workflowsStore.workflow.nodes.find((node) => node.type === type);
if (node) {
// This has to be kept up-to-date with the mode in the Chat-Trigger node
if (type === CHAT_TRIGGER_NODE_TYPE && !node.parameters.public) {
continue;
}
return {
type,
node,
};
}
}
return null;
});
const webhookUrl = computed(() => {
const url = `${rootStore.webhookUrl}${
webhookNode.value ? `/${webhookNode.value.node.webhookId}` : ''
}`;
return webhookNode.value?.type === CHAT_TRIGGER_NODE_TYPE ? `${url}/chat` : url;
});
function indentLines(code: string, indent: string = ' ') {
return code
.split('\n')
.map((line) => `${indent}${line}`)
.join('\n');
}
const importCode = 'import'; // To avoid vite from parsing the import statement
const commonCode = computed(() => ({
import: `${importCode} '@n8n/chat/style.css';
${importCode} { createChat } from '@n8n/chat';`,
createChat: `createChat({
webhookUrl: '${webhookUrl.value}'
});`,
install: 'npm install @n8n/chat',
}));
const cdnCode = computed(
() => `<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
<script type="module">
${importCode} { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
${commonCode.value.createChat}
</${'script'}>`,
);
const vueCode = computed(
() => `<script lang="ts" setup>
${importCode} { onMounted } from 'vue';
${commonCode.value.import}
onMounted(() => {
${indentLines(commonCode.value.createChat)}
});
</${'script'}>`,
);
const reactCode = computed(
() => `${importCode} { useEffect } from 'react';
${commonCode.value.import}
export const App = () => {
useEffect(() => {
${indentLines(commonCode.value.createChat, ' ')}
}, []);
return (<div></div>);
};
</${'script'}>`,
);
const otherCode = computed(
() => `${commonCode.value.import}
${commonCode.value.createChat}`,
);
function closeDialog() {
props.modalBus.emit('close');
}
</script>
<template>
<Modal
max-width="960px"
:title="i18n.baseText('chatEmbed.title')"
:event-bus="modalBus"
:name="CHAT_EMBED_MODAL_KEY"
:center="true"
>
<template #content>
<div :class="$style.container">
<N8nTabs v-model="currentTab" :options="tabs" />
<div v-if="currentTab !== 'cdn'">
<div class="mb-s">
<N8nText>
{{ i18n.baseText('chatEmbed.install') }}
</N8nText>
</div>
<HtmlEditor :model-value="commonCode.install" is-read-only />
</div>
<div class="mb-s">
<N8nText>
<I18nT :keypath="`chatEmbed.paste.${currentTab}`" scope="global">
<template #code>
<code>{{ i18n.baseText(`chatEmbed.paste.${currentTab}.file`) }}</code>
</template>
</I18nT>
</N8nText>
</div>
<HtmlEditor v-if="currentTab === 'cdn'" :model-value="cdnCode" is-read-only />
<HtmlEditor v-if="currentTab === 'vue'" :model-value="vueCode" is-read-only />
<JsEditor v-if="currentTab === 'react'" :model-value="reactCode" is-read-only />
<JsEditor v-if="currentTab === 'other'" :model-value="otherCode" is-read-only />
<N8nText>
{{ i18n.baseText('chatEmbed.packageInfo.description') }}
<N8nLink :href="i18n.baseText('chatEmbed.url')" new-window bold>
{{ i18n.baseText('chatEmbed.packageInfo.link') }}
</N8nLink>
</N8nText>
<N8nInfoTip class="mt-s">
{{ i18n.baseText('chatEmbed.chatTriggerNode') }}
</N8nInfoTip>
</div>
</template>
<template #footer>
<div class="action-buttons">
<N8nButton float="right" :label="i18n.baseText('chatEmbed.close')" @click="closeDialog" />
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.container > * {
margin-bottom: var(--spacing--sm);
overflow-wrap: break-word;
}
</style>
@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { N8nCard, N8nLoading } from '@n8n/design-system';
defineProps<{
loading: boolean;
title?: string;
}>();
</script>
<template>
<N8nCard :class="$style.card" v-bind="$attrs">
<template v-if="!loading && title" #header>
<span :class="$style.title" v-text="title" />
</template>
<N8nLoading :loading="loading" :rows="3" variant="p" />
<template v-if="!loading" #footer>
<slot name="footer" />
</template>
</N8nCard>
</template>
<style lang="scss" module>
.card {
min-width: 235px;
height: 140px;
margin-right: var(--spacing--2xs);
cursor: pointer;
&:last-child {
margin-right: var(--spacing--5xs);
}
&:hover {
box-shadow: 0 2px 4px rgba(68, 28, 23, 0.07);
}
> div {
height: 100%;
}
}
.title {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
font-size: var(--font-size--sm);
line-height: var(--line-height--md);
font-weight: var(--font-weight--bold);
overflow: hidden;
white-space: normal;
}
</style>
@@ -0,0 +1,115 @@
import { createComponentRenderer } from '@/__tests__/render';
import ConnectionTracker from '@/app/components/ConnectionTracker.vue';
import { STORES } from '@n8n/stores';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
let isConnected = true;
let isConnecting = false;
let isConnectionRequested = true;
let isOnline = true;
vi.mock('@/app/stores/pushConnection.store', () => {
return {
usePushConnectionStore: vi.fn(() => ({
isConnected,
isConnecting,
isConnectionRequested,
})),
};
});
vi.mock('@/app/stores/backendConnection.store', () => {
return {
useBackendConnectionStore: vi.fn(() => ({
isOnline,
})),
};
});
describe('ConnectionTracker', () => {
const render = () => {
const pinia = createTestingPinia({
stubActions: false,
initialState: {
[STORES.PUSH]: {
isConnected,
isConnecting,
isConnectionRequested,
},
},
});
setActivePinia(pinia);
return createComponentRenderer(ConnectionTracker, {
global: { stubs: { N8nIcon: true } },
})();
};
beforeEach(() => {
// Reset to default values
isConnected = true;
isConnecting = false;
isConnectionRequested = true;
isOnline = true;
});
it('should not render error when connected and online', () => {
isConnected = true;
isConnectionRequested = true;
isOnline = true;
const { container } = render();
expect(container).toMatchSnapshot();
});
it('should render network error when offline', () => {
isConnected = true;
isConnectionRequested = true;
isOnline = false; // Offline takes priority
const { container } = render();
expect(container).toMatchSnapshot();
expect(container.textContent).toContain('Offline');
});
it('should render push error when push disconnected and online', () => {
isConnected = false;
isConnecting = false;
isConnectionRequested = true;
isOnline = true;
const { container } = render();
expect(container).toMatchSnapshot();
expect(container.textContent).toContain('Connection lost');
});
it('should not render error when push is connecting', () => {
isConnected = false;
isConnecting = true;
isConnectionRequested = true;
isOnline = true;
const { container } = render();
expect(container).toMatchSnapshot();
});
it('should not render error when push connected and connection not requested', () => {
isConnected = true;
isConnectionRequested = false;
isOnline = true;
const { container } = render();
expect(container).toMatchSnapshot();
});
it('should render network error when both offline and push disconnected', () => {
isConnected = false;
isConnectionRequested = true;
isOnline = false; // Offline takes priority
const { container } = render();
expect(container).toMatchSnapshot();
expect(container.textContent).toContain('Offline');
});
});
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useBackendConnectionStore } from '@/app/stores/backendConnection.store';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import type { BaseTextKey } from '@n8n/i18n';
import { N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
const pushConnectionStore = usePushConnectionStore();
const backendConnectionStore = useBackendConnectionStore();
const i18n = useI18n();
type ConnectionStatus =
| {
hasError: true;
message: BaseTextKey;
tooltip: BaseTextKey;
}
| {
hasError: false;
message: null;
tooltip: null;
};
const connectionStatus = computed<ConnectionStatus>(() => {
// Priority 1: Check if backend is unreachable
if (!backendConnectionStore.isOnline) {
return {
hasError: true,
message: 'network.error.message',
tooltip: 'network.error.tooltip',
};
}
// Priority 2: Check if push connection is lost
// Only show the connection lost error if the connection has been requested,
// is not currently connected, and is not in the process of connecting.
// This prevents the error from showing during initial connection setup
// or when the user navigates to a page that doesn't open the connection.
if (
pushConnectionStore.isConnectionRequested &&
!pushConnectionStore.isConnected &&
!pushConnectionStore.isConnecting
) {
return {
hasError: true,
message: 'pushConnection.error.message',
tooltip: 'pushConnection.error.tooltip',
};
}
return {
hasError: false,
message: null,
tooltip: null,
};
});
</script>
<template>
<span>
<div v-if="connectionStatus.hasError" class="connection-lost">
<N8nTooltip placement="bottom-end">
<template #content>
<div v-n8n-html="i18n.baseText(connectionStatus.tooltip)"></div>
</template>
<span class="connection-lost-content">
<N8nIcon icon="triangle-alert" color="warning" />
<N8nText size="small">{{ i18n.baseText(connectionStatus.message) }}</N8nText>
</span>
</N8nTooltip>
</div>
<slot v-else />
</span>
</template>
<style scoped>
.connection-lost {
display: flex;
align-items: center;
}
.connection-lost-content {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
}
</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { type LlmTokenUsageData } from '@/Interface';
import { formatTokenUsageCount } from '@/app/utils/aiUtils';
import { N8nText } from '@n8n/design-system';
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
const i18n = useI18n();
</script>
<template>
<div>
<N8nText :bold="true" size="small">
{{ i18n.baseText('runData.aiContentBlock.tokens.prompt') }}
{{
i18n.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokens, 'prompt'),
},
})
}}
</N8nText>
<br />
<N8nText :bold="true" size="small">
{{ i18n.baseText('runData.aiContentBlock.tokens.completion') }}
{{
i18n.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokens, 'completion'),
},
})
}}
</N8nText>
</div>
</template>
@@ -0,0 +1,47 @@
import merge from 'lodash/merge';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { createTestingPinia } from '@pinia/testing';
import CopyInput from '@/app/components/CopyInput.vue';
import { createComponentRenderer } from '@/__tests__/render';
const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
},
},
}),
props: {
copyButtonText: 'Click to copy',
label: 'Copy Input test',
},
};
const renderComponent = createComponentRenderer(CopyInput, DEFAULT_SETUP);
describe('BannerStack', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('should render default configuration', async () => {
const { getByTestId } = renderComponent();
expect(getByTestId('input-label')).toHaveTextContent('Copy Input test');
expect(getByTestId('copy-input')).toHaveTextContent('Click to copy');
});
it('should render redacted version', async () => {
const { getByTestId } = renderComponent(
merge(DEFAULT_SETUP, {
props: {
redactValue: true,
},
}),
);
expect(getByTestId('copy-input')).toHaveClass('ph-no-capture');
});
});
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { useClipboard } from '@/app/composables/useClipboard';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
import { N8nInputLabel } from '@n8n/design-system';
type Props = {
label?: string;
hint?: string;
value?: string;
copyButtonText?: string;
toastTitle?: string;
toastMessage?: string;
size?: 'medium' | 'large';
collapse?: boolean;
redactValue?: boolean;
disableCopy?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
value: '',
placeholder: '',
label: '',
hint: '',
size: 'medium',
copyButtonText: useI18n().baseText('generic.copy'),
toastTitle: useI18n().baseText('generic.copiedToClipboard'),
disableCopy: false,
});
const emit = defineEmits<{
copy: [];
}>();
const clipboard = useClipboard();
const { showMessage } = useToast();
function copy() {
if (props.disableCopy) return;
emit('copy');
void clipboard.copy(props.value ?? '');
showMessage({
title: props.toastTitle,
message: props.toastMessage,
type: 'success',
});
}
</script>
<template>
<div>
<N8nInputLabel :label="label">
<div
:class="{
[$style.copyText]: true,
[$style[size]]: true,
[$style.collapsed]: collapse,
[$style.noHover]: disableCopy,
'ph-no-capture': redactValue,
}"
data-test-id="copy-input"
@click="copy"
>
<span ref="copyInputValue">{{ value }}</span>
<div v-if="!disableCopy" :class="$style.copyButton">
<span>{{ copyButtonText }}</span>
</div>
</div>
</N8nInputLabel>
<div v-if="hint" :class="$style.hint">{{ hint }}</div>
</div>
</template>
<style lang="scss" module>
.copyText {
span {
font-family: Monaco, Consolas;
color: var(--color--text);
overflow-wrap: break-word;
}
padding: var(--spacing--xs);
background-color: var(--color--background--light-2);
border: var(--border);
border-radius: var(--radius);
cursor: pointer;
position: relative;
font-weight: var(--font-weight--regular);
&:hover {
/* stylelint-disable-next-line @n8n/css-var-naming */
--display-copy-button: flex;
width: 100%;
}
}
.noHover {
cursor: default;
}
.large {
span {
font-size: var(--font-size--sm);
line-height: 1.5;
}
}
.medium {
span {
font-size: var(--font-size--xs);
line-height: 1;
}
}
.collapsed {
white-space: nowrap;
overflow: hidden;
}
.copyButton {
/* stylelint-disable-next-line @n8n/css-var-naming */
display: var(--display-copy-button, none);
position: absolute;
top: 0;
right: 0;
padding: var(--spacing--xs);
background-color: var(--color--background--light-2);
height: 100%;
align-items: center;
border-radius: var(--radius);
span {
font-family: unset;
}
}
.hint {
margin-top: var(--spacing--2xs);
font-size: var(--font-size--2xs);
line-height: var(--line-height--lg);
font-weight: var(--font-weight--regular);
word-break: normal;
}
</style>
@@ -0,0 +1,434 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@/app/composables/useToast';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY } from '../constants';
import * as restApiClient from '@n8n/rest-api-client';
import type { CredentialResolverType } from '@n8n/api-types';
import { defineComponent, h, watch, toRefs } from 'vue';
vi.mock('@/app/composables/useToast', () => {
const showError = vi.fn();
return {
useToast: () => ({
showError,
}),
};
});
vi.mock('@n8n/rest-api-client', async (importOriginal) => {
const actual = await importOriginal<typeof restApiClient>();
return {
...actual,
getCredentialResolverTypes: vi.fn(),
getCredentialResolver: vi.fn(),
createCredentialResolver: vi.fn(),
updateCredentialResolver: vi.fn(),
};
});
const ModalStub = {
template: `
<div>
<slot name="header" />
<slot name="content" />
</div>
`,
};
const CredentialInputsStub = {
template: '<div data-test-id="credential-inputs" />',
};
const global = {
stubs: {
Modal: ModalStub,
CredentialInputs: CredentialInputsStub,
},
};
const mockResolverTypes: CredentialResolverType[] = [
{
name: 'test-resolver',
displayName: 'Test Resolver',
description: 'A test resolver',
options: [
{
name: 'apiKey',
type: 'string',
displayName: 'API Key',
required: true,
description: 'Enter your API key',
placeholder: 'Enter key',
},
],
},
];
const mockOAuthResolverTypes: CredentialResolverType[] = [
{
name: 'credential-resolver.oauth2-1.0',
displayName: 'OAuth2 Resolver',
description: 'OAuth2 based credential resolver',
options: [
{
displayName: 'Metadata URL',
name: 'metadataUri',
type: 'string',
required: true,
default: '',
},
{
displayName: 'Validation Method',
name: 'validation',
type: 'options',
options: [
{ name: 'OAuth2 Token Introspection', value: 'oauth2-introspection' },
{ name: 'OAuth2 UserInfo Endpoint', value: 'oauth2-userinfo' },
],
default: 'oauth2-introspection',
},
{
displayName: 'Client ID',
name: 'clientId',
type: 'string',
default: '',
displayOptions: {
hide: { validation: ['oauth2-userinfo'] },
show: { validation: ['oauth2-introspection'] },
},
},
{
displayName: 'Client Secret',
name: 'clientSecret',
type: 'string',
default: '',
displayOptions: {
hide: { validation: ['oauth2-userinfo'] },
show: { validation: ['oauth2-introspection'] },
},
},
{
displayName: 'Subject Claim',
name: 'subjectClaim',
type: 'string',
default: 'sub',
},
],
},
];
/**
* Creates a spy component that replaces CredentialInputs, capturing props on
* every render and optionally exposing an `emit('update', ...)` trigger.
*/
function createCredentialInputsSpy({ withEmit = false } = {}) {
const capturedProps: Array<Record<string, unknown>> = [];
let triggerUpdate: ((data: { name: string; value: unknown }) => void) | undefined;
const component = defineComponent({
props: ['credentialProperties', 'credentialData', 'documentationUrl', 'showValidationWarnings'],
emits: ['update'],
setup(props, { emit }) {
if (withEmit) {
triggerUpdate = (data) => emit('update', data);
}
// Capture initial props
capturedProps.push({ ...props });
// Re-capture on every prop change
const refs = toRefs(props);
watch(
() => Object.values(refs).map((r) => r.value),
() => capturedProps.push({ ...props }),
);
return () => h('div', { 'data-test-id': 'credential-inputs' });
},
});
return { capturedProps, component, getTriggerUpdate: () => triggerUpdate };
}
function getPropertyNames(capturedProps: Array<Record<string, unknown>>) {
const lastProps = capturedProps[capturedProps.length - 1];
return (lastProps.credentialProperties as Array<{ name: string }>).map((p) => p.name);
}
const renderModal = createComponentRenderer(CredentialResolverEditModal);
let pinia: ReturnType<typeof createTestingPinia>;
describe('CredentialResolverEditModal', () => {
let rootStore: MockedStore<typeof useRootStore>;
let toast: ReturnType<typeof useToast>;
beforeEach(() => {
pinia = createTestingPinia();
rootStore = mockedStore(useRootStore);
toast = useToast();
rootStore.restApiContext = {
baseUrl: 'http://localhost',
pushRef: 'test-ref',
};
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Component rendering', () => {
it('should load resolver types on mount', async () => {
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
},
pinia,
global,
});
await vi.waitFor(() => {
expect(restApiClient.getCredentialResolverTypes).toHaveBeenCalledWith(
rootStore.restApiContext,
);
});
});
});
describe('Create resolver', () => {
it('should render in create mode without resolverId', async () => {
const onSave = vi.fn();
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave,
},
},
pinia,
global,
});
// Wait for types to load
await vi.waitFor(() => {
expect(restApiClient.getCredentialResolverTypes).toHaveBeenCalled();
});
// Verify it doesn't try to load an existing resolver
expect(restApiClient.getCredentialResolver).not.toHaveBeenCalled();
});
});
describe('Edit resolver', () => {
it('should load existing resolver when resolverId is provided', async () => {
const mockResolver = {
id: 'existing-resolver-id',
name: 'Existing Resolver',
type: 'test-resolver',
config: '{}',
decryptedConfig: { apiKey: 'secret-key' },
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(restApiClient.getCredentialResolver).mockResolvedValue(mockResolver);
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: 'existing-resolver-id',
},
},
pinia,
global,
});
await vi.waitFor(() => {
expect(restApiClient.getCredentialResolver).toHaveBeenCalledWith(
rootStore.restApiContext,
'existing-resolver-id',
);
});
});
});
describe('displayOptions filtering', () => {
it('should hide clientId and clientSecret when validation is oauth2-userinfo', async () => {
const { capturedProps, component } = createCredentialInputsSpy();
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockOAuthResolverTypes);
vi.mocked(restApiClient.getCredentialResolver).mockResolvedValue({
id: 'oauth-resolver-id',
name: 'OAuth Resolver',
type: 'credential-resolver.oauth2-1.0',
config: '{}',
decryptedConfig: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
validation: 'oauth2-userinfo',
subjectClaim: 'sub',
},
createdAt: new Date(),
updatedAt: new Date(),
});
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: { resolverId: 'oauth-resolver-id' },
},
pinia,
global: { stubs: { Modal: ModalStub, CredentialInputs: component } },
});
await vi.waitFor(() => {
expect(capturedProps.length).toBeGreaterThan(0);
});
const propertyNames = getPropertyNames(capturedProps);
expect(propertyNames).toContain('metadataUri');
expect(propertyNames).toContain('validation');
expect(propertyNames).toContain('subjectClaim');
expect(propertyNames).not.toContain('clientId');
expect(propertyNames).not.toContain('clientSecret');
});
it('should show clientId and clientSecret when validation is oauth2-introspection', async () => {
const { capturedProps, component } = createCredentialInputsSpy();
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockOAuthResolverTypes);
vi.mocked(restApiClient.getCredentialResolver).mockResolvedValue({
id: 'oauth-resolver-id',
name: 'OAuth Resolver',
type: 'credential-resolver.oauth2-1.0',
config: '{}',
decryptedConfig: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
validation: 'oauth2-introspection',
clientId: 'my-client',
clientSecret: 'my-secret',
subjectClaim: 'sub',
},
createdAt: new Date(),
updatedAt: new Date(),
});
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: { resolverId: 'oauth-resolver-id' },
},
pinia,
global: { stubs: { Modal: ModalStub, CredentialInputs: component } },
});
await vi.waitFor(() => {
expect(capturedProps.length).toBeGreaterThan(0);
});
const propertyNames = getPropertyNames(capturedProps);
expect(propertyNames).toContain('metadataUri');
expect(propertyNames).toContain('validation');
expect(propertyNames).toContain('clientId');
expect(propertyNames).toContain('clientSecret');
expect(propertyNames).toContain('subjectClaim');
});
it('should reactively hide fields when user switches validation to oauth2-userinfo', async () => {
const { capturedProps, component, getTriggerUpdate } = createCredentialInputsSpy({
withEmit: true,
});
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockOAuthResolverTypes);
vi.mocked(restApiClient.getCredentialResolver).mockResolvedValue({
id: 'oauth-resolver-id',
name: 'OAuth Resolver',
type: 'credential-resolver.oauth2-1.0',
config: '{}',
decryptedConfig: {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
validation: 'oauth2-introspection',
clientId: 'my-client',
clientSecret: 'my-secret',
subjectClaim: 'sub',
},
createdAt: new Date(),
updatedAt: new Date(),
});
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: { resolverId: 'oauth-resolver-id' },
},
pinia,
global: { stubs: { Modal: ModalStub, CredentialInputs: component } },
});
await vi.waitFor(() => {
expect(capturedProps.length).toBeGreaterThan(0);
expect(getTriggerUpdate()).toBeDefined();
});
expect(getPropertyNames(capturedProps)).toContain('clientId');
expect(getPropertyNames(capturedProps)).toContain('clientSecret');
// Simulate user switching validation method
const propsCountBefore = capturedProps.length;
getTriggerUpdate()!({ name: 'validation', value: 'oauth2-userinfo' });
await vi.waitFor(() => {
expect(capturedProps.length).toBeGreaterThan(propsCountBefore);
expect(getPropertyNames(capturedProps)).not.toContain('clientId');
expect(getPropertyNames(capturedProps)).not.toContain('clientSecret');
});
});
});
describe('Error handling', () => {
it('should show error toast when loading resolver types fails', async () => {
const error = new Error('Failed to load types');
vi.mocked(restApiClient.getCredentialResolverTypes).mockRejectedValue(error);
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
},
pinia,
global,
});
await vi.waitFor(() => {
expect(toast.showError).toHaveBeenCalledWith(error, expect.any(String));
});
});
it('should show error toast when loading resolver fails', async () => {
const error = new Error('Load failed');
vi.mocked(restApiClient.getCredentialResolver).mockRejectedValue(error);
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: 'existing-resolver-id',
},
},
pinia,
global,
});
await vi.waitFor(() => {
expect(toast.showError).toHaveBeenCalledWith(error, expect.any(String));
});
});
});
});
@@ -0,0 +1,651 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import {
N8nIconButton,
N8nSelect,
N8nOption,
N8nMenuItem,
N8nText,
N8nInlineTextEdit,
N8nCallout,
type IMenuItem,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY } from '../constants';
import { createEventBus } from '@n8n/utils/event-bus';
import Modal from './Modal.vue';
import SaveButton from './SaveButton.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { CredentialResolver } from '@n8n/api-types';
import {
getCredentialResolver,
createCredentialResolver,
updateCredentialResolver,
} from '@n8n/rest-api-client';
import { useCredentialResolvers } from '@/features/resolvers/composables/useCredentialResolvers';
import type {
INodeProperties,
INodeParameters,
ICredentialDataDecryptedObject,
CredentialInformation,
} from 'n8n-workflow';
import { deepCopy, NodeHelpers } from 'n8n-workflow';
import type { IUpdateInformation } from '@/Interface';
import CredentialInputs from '@/features/credentials/components/CredentialEdit/CredentialInputs.vue';
const props = defineProps<{
modalName: string;
data?: {
resolverId?: string;
onSave?: (resolverId: string) => void;
onDelete?: (resolverId: string) => void;
};
}>();
const modalBus = createEventBus();
const i18n = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const activeTab = ref('configuration');
const isLoading = ref(false);
const isSaving = ref(false);
const resolverName = ref('');
const resolverType = ref('');
const resolverConfig = ref<Record<string, unknown>>({});
const clearCredentials = ref<boolean | null>(null);
const hasUnsavedChanges = ref(false);
const errorMessage = ref<string>('');
const mainContentRef = ref<HTMLElement>();
// Store original values to detect non-name changes
const originalResolverName = ref('');
const originalResolverType = ref('');
const originalResolverConfig = ref<Record<string, unknown>>({});
// Track if user has ever made a non-name change (stays true even if reverted)
const hasEverMadeNonNameChange = ref(false);
const {
resolverTypes: availableTypes,
fetchResolverTypes: loadResolverTypes,
deleteResolver: deleteResolverFromComposable,
isDeleting,
} = useCredentialResolvers();
const isEditMode = computed(() => !!props.data?.resolverId);
// Type guard to validate and convert resolver config to credential data
const isCredentialInformation = (value: unknown): value is CredentialInformation => {
// Check for primitive types (string, number, boolean)
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return true;
}
// Check for arrays (can be string[] or IDataObject[])
if (Array.isArray(value)) {
// Accept arrays where all elements are either strings or objects
return value.every(
(v) => typeof v === 'string' || (typeof v === 'object' && v !== null && !Array.isArray(v)),
);
}
// Check for IDataObject (plain object, not null or array)
if (typeof value === 'object' && value !== null) {
return true;
}
return false;
};
const toCredentialData = (config: Record<string, unknown>): ICredentialDataDecryptedObject => {
const result: ICredentialDataDecryptedObject = {};
for (const [key, value] of Object.entries(config)) {
if (isCredentialInformation(value)) {
result[key] = value;
} else if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
// Convert primitive values to string for compatibility
result[key] = String(value);
}
}
return result;
};
const selectedType = computed(() => {
return availableTypes.value.find((t) => t.name === resolverType.value);
});
// Helper function to convert resolver option to INodeProperties
// The explicit return type provides type narrowing from unknown to INodeProperties
const toNodeProperty = (option: Record<string, unknown>): INodeProperties => {
return {
...option,
name: typeof option.name === 'string' ? option.name : '',
type: (typeof option.type === 'string' ? option.type : 'string') as INodeProperties['type'],
displayName: typeof option.displayName === 'string' ? option.displayName : '',
default: (option.default ?? '') as INodeProperties['default'],
...(typeof option.required === 'boolean' && { required: option.required }),
...(typeof option.description === 'string' && { description: option.description }),
...(typeof option.placeholder === 'string' && { placeholder: option.placeholder }),
};
};
const resolverProperties = computed<INodeProperties[]>(() => {
if (!selectedType.value?.options) return [];
// Transform resolver options to INodeProperties format and filter by displayOptions
return selectedType.value.options.map(toNodeProperty).filter((property) => {
if (property.type === 'hidden') return false;
if (!property.displayOptions) return true;
return NodeHelpers.displayParameterPath(
resolverData.value as INodeParameters,
property,
'',
null,
null,
);
});
});
const resolverData = computed<ICredentialDataDecryptedObject>(() => {
return toCredentialData(resolverConfig.value);
});
const requiredPropertiesFilled = computed(() => {
for (const property of resolverProperties.value) {
if (property.required !== true) {
continue;
}
const resolverProperty = resolverData.value[property.name];
if (property.type === 'string' && !resolverProperty) {
return false;
}
if (property.type === 'number') {
const containsExpression =
typeof resolverProperty === 'string' && resolverProperty.startsWith('=');
if (typeof resolverProperty !== 'number' && !containsExpression) {
return false;
}
}
}
return true;
});
// Check if non-name fields have changed
const hasNonNameChanges = computed(() => {
if (!isEditMode.value) return false;
const typeChanged = originalResolverType.value !== resolverType.value;
const configChanged =
JSON.stringify(originalResolverConfig.value) !== JSON.stringify(resolverConfig.value);
// Update the flag if there's a current change
if (typeChanged || configChanged) {
hasEverMadeNonNameChange.value = true;
}
// Show dropdown if user has ever made a non-name change
return hasEverMadeNonNameChange.value;
});
const canSave = computed(() => {
const baseCheck = resolverName.value.trim() !== '' && resolverType.value !== '';
if (isEditMode.value && hasNonNameChanges.value) {
return baseCheck && clearCredentials.value !== null;
}
return baseCheck;
});
const sidebarItems = computed<IMenuItem[]>(() => [
{
id: 'configuration',
label: i18n.baseText('credentialResolverEdit.sidebar.configuration'),
position: 'top',
},
{
id: 'details',
label: i18n.baseText('credentialResolverEdit.sidebar.details'),
position: 'top',
},
]);
const loadResolver = async () => {
if (!props.data?.resolverId) return;
try {
const resolver = await getCredentialResolver(rootStore.restApiContext, props.data.resolverId);
resolverName.value = resolver.name;
resolverType.value = resolver.type;
resolverConfig.value = resolver.decryptedConfig || {};
// Store original values for change detection
originalResolverName.value = resolver.name;
originalResolverType.value = resolver.type;
originalResolverConfig.value = deepCopy(resolver.decryptedConfig || {});
// Reset the flag when loading a resolver
hasEverMadeNonNameChange.value = false;
clearCredentials.value = null;
} catch (error) {
toast.showError(error, i18n.baseText('credentialResolverEdit.error.save'));
}
};
const beforeClose = () => {
if (hasUnsavedChanges.value) {
// Could add confirmation dialog here
}
return true;
};
const save = async () => {
if (!canSave.value) return;
isSaving.value = true;
errorMessage.value = '';
if (!requiredPropertiesFilled.value) {
errorMessage.value = i18n.baseText('credentialResolverEdit.error.missingRequiredFields');
isSaving.value = false;
return;
}
if (isEditMode.value && hasNonNameChanges.value && clearCredentials.value === null) {
errorMessage.value = i18n.baseText('credentialResolverEdit.clearCredentials.error.required');
isSaving.value = false;
return;
}
try {
const payload: {
name: string;
type: string;
config: Record<string, unknown>;
clearCredentials?: boolean;
} = {
name: resolverName.value.trim(),
type: resolverType.value,
config: resolverConfig.value,
};
// Include clearCredentials if non-name fields changed, otherwise default to false
if (isEditMode.value) {
if (hasNonNameChanges.value && clearCredentials.value !== null) {
payload.clearCredentials = clearCredentials.value;
} else if (!hasNonNameChanges.value) {
// Only name changed, default to false
payload.clearCredentials = false;
}
}
let savedResolver: CredentialResolver;
if (isEditMode.value && props.data?.resolverId) {
savedResolver = await updateCredentialResolver(
rootStore.restApiContext,
props.data.resolverId,
payload,
);
} else {
savedResolver = await createCredentialResolver(rootStore.restApiContext, payload);
}
if (props.data?.onSave) {
props.data.onSave(savedResolver.id);
}
toast.showMessage({
title: i18n.baseText('credentialResolverEdit.saveSuccess.title'),
type: 'success',
});
hasUnsavedChanges.value = false;
modalBus.emit('close');
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : i18n.baseText('credentialResolverEdit.error.save');
// Scroll to top to show error message
mainContentRef.value?.scrollTo({ top: 0, behavior: 'smooth' });
} finally {
isSaving.value = false;
}
};
const onConfigUpdate = (updateData: IUpdateInformation) => {
resolverConfig.value = {
...resolverConfig.value,
[updateData.name]: updateData.value,
};
hasUnsavedChanges.value = true;
errorMessage.value = '';
// Reset clearCredentials when config changes to force user to make a choice
if (isEditMode.value) {
clearCredentials.value = null;
}
};
const onNameEdit = (newName: string) => {
resolverName.value = newName;
hasUnsavedChanges.value = true;
};
const onTabSelect = (tabId: string) => {
activeTab.value = tabId;
};
const deleteResolver = async () => {
if (!props.data?.resolverId) return;
// Create a resolver object for the composable
const resolver: CredentialResolver = {
id: props.data.resolverId,
name: resolverName.value,
type: resolverType.value,
config: JSON.stringify(resolverConfig.value),
createdAt: new Date(),
updatedAt: new Date(),
};
const deleted = await deleteResolverFromComposable(resolver);
if (deleted) {
hasUnsavedChanges.value = false;
if (props.data?.onDelete) {
props.data.onDelete(props.data.resolverId);
}
modalBus.emit('close');
}
};
onMounted(async () => {
isLoading.value = true;
await loadResolverTypes();
if (isEditMode.value) {
await loadResolver();
} else {
// Set default name for new resolvers
resolverName.value = i18n.baseText('credentialResolverEdit.defaultName');
clearCredentials.value = null;
}
isLoading.value = false;
});
</script>
<template>
<Modal
:name="CREDENTIAL_RESOLVER_EDIT_MODAL_KEY"
:custom-class="$style.resolverModal"
:event-bus="modalBus"
:loading="isLoading"
:before-close="beforeClose"
width="70%"
height="80%"
>
<template #header>
<div :class="$style.header">
<div :class="$style.resolverInfo">
<div :class="$style.resolverIcon">
<N8nIconButton
variant="subtle"
icon="database"
size="large"
:disabled="true"
:aria-label="i18n.baseText('credentialResolverEdit.icon')"
/>
</div>
<div :class="$style.resolverName">
<N8nInlineTextEdit
v-if="resolverName"
data-test-id="credential-resolver-name"
:model-value="resolverName"
@update:model-value="onNameEdit"
/>
<N8nText v-if="selectedType" size="small" tag="p" color="text-light">
{{ selectedType.displayName }}
</N8nText>
</div>
</div>
<div :class="$style.resolverActions">
<N8nIconButton
variant="subtle"
v-if="isEditMode"
:title="i18n.baseText('credentialResolverEdit.delete')"
icon="trash-2"
:disabled="isSaving"
:loading="isDeleting"
data-test-id="credential-resolver-delete-button"
@click="deleteResolver"
/>
<SaveButton
:saved="false"
:is-saving="isSaving"
:disabled="!canSave"
data-test-id="credential-resolver-save-button"
@click="save"
/>
</div>
</div>
</template>
<template #content>
<div :class="$style.container" data-test-id="credential-resolver-edit-dialog">
<div :class="$style.sidebar">
<N8nMenuItem
v-for="item in sidebarItems"
:key="item.id"
:item="item"
:active="activeTab === item.id"
@click="() => onTabSelect(item.id)"
/>
</div>
<div v-if="activeTab === 'configuration'" ref="mainContentRef" :class="$style.mainContent">
<N8nCallout
v-if="errorMessage"
theme="danger"
:class="$style.errorAlert"
data-test-id="credential-resolver-error-alert"
>
{{ errorMessage }}
</N8nCallout>
<div v-if="isEditMode && hasNonNameChanges" :class="$style.formGroup">
<label :class="$style.label">
{{ i18n.baseText('credentialResolverEdit.clearCredentials.label') }}
</label>
<N8nSelect
v-model="clearCredentials"
:placeholder="i18n.baseText('credentialResolverEdit.clearCredentials.placeholder')"
data-test-id="credential-resolver-clear-credentials-select"
@update:model-value="
() => {
hasUnsavedChanges = true;
errorMessage = '';
}
"
>
<N8nOption
:label="i18n.baseText('credentialResolverEdit.clearCredentials.yes')"
:value="true"
>
</N8nOption>
<N8nOption
:label="i18n.baseText('credentialResolverEdit.clearCredentials.no')"
:value="false"
>
</N8nOption>
</N8nSelect>
</div>
<N8nCallout
v-if="isEditMode && hasNonNameChanges"
theme="warning"
:class="$style.warningAlert"
data-test-id="credential-resolver-clear-credentials-warning"
>
{{ i18n.baseText('credentialResolverEdit.clearCredentials.warning') }}
</N8nCallout>
<div :class="$style.formGroup">
<label :class="$style.label">
{{ i18n.baseText('credentialResolverEdit.type.label') }}
</label>
<N8nSelect
v-model="resolverType"
:placeholder="i18n.baseText('credentialResolverEdit.type.placeholder')"
data-test-id="credential-resolver-type-select"
@update:model-value="
() => {
hasUnsavedChanges = true;
errorMessage = '';
clearCredentials = null;
}
"
>
<N8nOption
v-for="type in availableTypes"
:key="type.name"
:label="type.displayName"
:value="type.name"
>
</N8nOption>
</N8nSelect>
</div>
<div v-if="resolverProperties.length > 0" :class="$style.configSection">
<CredentialInputs
:credential-properties="resolverProperties"
:credential-data="resolverData"
documentation-url=""
:show-validation-warnings="!!errorMessage"
@update="onConfigUpdate"
/>
</div>
</div>
<div v-if="activeTab === 'details'" :class="$style.mainContent">
<div :class="$style.formGroup">
<label :class="$style.label">
{{ i18n.baseText('credentialResolverEdit.details.id') }}
</label>
<N8nText v-if="props.data?.resolverId" color="text-base">
{{ props.data.resolverId }}
</N8nText>
<N8nText v-else color="text-light">
{{ i18n.baseText('credentialResolverEdit.details.notSaved') }}
</N8nText>
</div>
</div>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.resolverModal {
--dialog--max-width: 1200px;
--dialog--close--spacing--top: 31px;
--dialog--max-height: 750px;
:global(.el-dialog__header) {
padding-bottom: 0;
border-bottom: var(--border);
}
:global(.el-dialog__body) {
padding-top: var(--spacing--lg);
position: relative;
}
}
.mainContent {
flex: 1;
overflow: auto;
padding-bottom: 100px;
padding-inline: var(--spacing--4xs);
}
.resolverName {
display: flex;
width: 100%;
flex-direction: column;
gap: var(--spacing--4xs);
}
.sidebar {
max-width: 170px;
min-width: 170px;
margin-right: var(--spacing--lg);
flex-grow: 1;
ul {
padding: 0 !important;
}
}
.header {
display: flex;
}
.container {
display: flex;
height: 100%;
}
.resolverInfo {
display: flex;
align-items: center;
flex-direction: row;
flex-grow: 1;
margin-bottom: var(--spacing--lg);
}
.resolverActions {
display: flex;
flex-direction: row;
align-items: center;
margin-right: var(--spacing--xl);
margin-bottom: var(--spacing--lg);
> * {
margin-left: var(--spacing--2xs);
}
}
.resolverIcon {
display: flex;
align-items: center;
margin-right: var(--spacing--xs);
}
.formGroup {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--md);
}
.label {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text);
}
.configSection {
margin-top: var(--spacing--lg);
}
.errorAlert {
margin-bottom: var(--spacing--md);
}
.warningAlert {
margin-bottom: var(--spacing--md);
}
</style>
@@ -0,0 +1,232 @@
import { describe, it, expect } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import Draggable from './Draggable.vue';
describe('Draggable', () => {
const renderComponent = createComponentRenderer(Draggable, {
props: {
type: 'move' as const,
targetDataKey: 'test',
},
slots: {
default: '<div data-target="test" data-value="test-data">Drag me</div>',
preview: '<div class="preview">Preview</div>',
},
});
const simulateMouseDown = (element: HTMLElement, x: number, y: number) => {
const event = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
buttons: 1,
});
element.dispatchEvent(event);
};
const simulateMouseMove = (x: number, y: number) => {
const event = new MouseEvent('mousemove', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
});
window.dispatchEvent(event);
};
const simulateMouseUp = () => {
const event = new MouseEvent('mouseup', {
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
};
const waitForAnimationFrame = async () =>
await new Promise((resolve) => requestAnimationFrame(resolve));
describe('dragThreshold prop', () => {
it('should have default threshold of 10px', () => {
const { emitted } = renderComponent();
// Distance below threshold
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
simulateMouseMove(106, 106);
expect(emitted().dragstart).toBeUndefined();
// Move beyond threshold should trigger
simulateMouseMove(108, 108);
expect(emitted().dragstart).toHaveLength(1);
simulateMouseUp();
});
it('should accept custom threshold value', () => {
const { emitted } = renderComponent({ props: { dragThreshold: 15 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
// 12px movement should not trigger with 15px threshold
simulateMouseMove(108, 108); // distance = 11.3px < 15px
expect(emitted().dragstart).toBeUndefined();
// 16px movement should trigger
simulateMouseMove(112, 112); // distance = sqrt(144 + 144) = 16.97px > 15px
expect(emitted().dragstart).toHaveLength(1);
simulateMouseUp();
});
});
describe('drag threshold behavior', () => {
it('should not emit dragstart when mouse moves less than threshold', () => {
// Set threshold to 10px
const { emitted } = renderComponent({ props: { dragThreshold: 10 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
// Move mouse 5px
simulateMouseMove(103, 104);
expect(emitted().dragstart).toBeUndefined();
simulateMouseUp();
});
it('should emit dragstart when mouse moves beyond threshold', () => {
// Set threshold to 5px
const { emitted } = renderComponent({ props: { dragThreshold: 5 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
// Move mouse 10px
simulateMouseMove(106, 108);
expect(emitted().dragstart).toHaveLength(1);
simulateMouseUp();
});
it('should emit dragstart exactly when threshold is reached', () => {
const { emitted } = renderComponent({ props: { dragThreshold: 5 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
// Move mouse just under threshold (should not trigger)
simulateMouseMove(103, 103);
expect(emitted().dragstart).toBeUndefined();
// Move to exactly threshold (should trigger drag)
simulateMouseMove(103, 104);
expect(emitted().dragstart).toHaveLength(1);
simulateMouseUp();
});
it('should immediately start drag when threshold is 0', () => {
const { emitted } = renderComponent({ props: { dragThreshold: 0 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
simulateMouseMove(101, 100); // distance = 1px
expect(emitted().dragstart).toHaveLength(1);
simulateMouseUp();
});
it('should not emit dragstart on click without mouse movement', () => {
const { emitted } = renderComponent({ props: { dragThreshold: 5 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
simulateMouseUp();
expect(emitted().dragstart).toBeUndefined();
});
it('should not emit dragend if drag was never started (threshold not exceeded)', async () => {
const { emitted } = renderComponent({ props: { dragThreshold: 10 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
// Move less than threshold
simulateMouseMove(102, 102);
simulateMouseUp();
// Wait for setTimeout in onDragEnd
await new Promise((resolve) => setTimeout(resolve, 10));
// dragend should NOT be emitted since dragstart was never emitted
// This ensures drag lifecycle events remain paired
expect(emitted().dragend).toBeUndefined();
});
});
describe('drag behavior after threshold exceeded', () => {
it('should continue emitting drag events after threshold is exceeded', async () => {
const { emitted } = renderComponent({ props: { dragThreshold: 5 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
// Exceed threshold
simulateMouseMove(110, 110);
expect(emitted().dragstart).toHaveLength(1);
await waitForAnimationFrame();
// Continue moving
simulateMouseMove(120, 120);
await waitForAnimationFrame();
simulateMouseMove(130, 130);
await waitForAnimationFrame();
// drag events should be emitted
expect(emitted().drag).toBeDefined();
expect(emitted().drag!.length).toBeGreaterThanOrEqual(1);
simulateMouseUp();
});
it('should only emit dragstart once regardless of continued movement', () => {
const { emitted } = renderComponent({ props: { dragThreshold: 5 } });
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
simulateMouseMove(110, 110);
simulateMouseMove(120, 120);
simulateMouseMove(130, 130);
expect(emitted().dragstart).toHaveLength(1);
simulateMouseUp();
});
});
describe('disabled state', () => {
it('should not start drag when disabled', () => {
const { emitted } = renderComponent({
props: { disabled: true, dragThreshold: 5 },
});
const draggableEl = document.querySelector('[data-target="test"]') as HTMLElement;
simulateMouseDown(draggableEl, 100, 100);
simulateMouseMove(120, 120);
expect(emitted().dragstart).toBeUndefined();
simulateMouseUp();
});
});
});
@@ -0,0 +1,178 @@
<script setup lang="ts">
import type { DraggableMode, XYPosition } from '@/Interface';
import { isPresent } from '@/app/utils/typesUtils';
import { type StyleValue, computed, onBeforeUnmount, ref } from 'vue';
type Props = {
type: DraggableMode;
data?: string | null;
tag?: keyof HTMLElementTagNameMap;
targetDataKey?: string | null;
disabled?: boolean;
canDrop?: boolean;
stickyPosition?: XYPosition | null;
/** Minimum distance in pixels the mouse must move before drag operation starts. Prevents accidental drags. */
dragThreshold?: number;
};
const props = withDefaults(defineProps<Props>(), {
data: null,
tag: 'div',
targetDataKey: null,
disabled: false,
canDrop: false,
stickyPosition: null,
dragThreshold: 10,
});
const emit = defineEmits<{
drag: [value: XYPosition];
dragstart: [value: HTMLElement, data: string | undefined];
dragend: [value: HTMLElement];
}>();
const isDragging = ref(false);
const draggingElement = ref<HTMLElement>();
const draggablePosition = ref<XYPosition>([0, 0]);
const animationFrameId = ref<number>();
const dragStartPosition = ref<XYPosition | null>(null);
const draggableStyle = computed<StyleValue>(() => ({
transform: `translate(${draggablePosition.value[0]}px, ${draggablePosition.value[1]}px)`,
}));
const onDragStart = (event: MouseEvent) => {
if (props.disabled || event.buttons !== 1) {
return;
}
draggingElement.value = event.target as HTMLElement;
if (props.targetDataKey && draggingElement.value.dataset?.target !== props.targetDataKey) {
draggingElement.value = draggingElement.value.closest(
`[data-target="${props.targetDataKey}"]`,
) as HTMLElement;
}
if (props.targetDataKey && draggingElement.value?.dataset?.target !== props.targetDataKey) {
return;
}
event.preventDefault();
event.stopPropagation();
isDragging.value = false;
dragStartPosition.value = [event.pageX, event.pageY];
draggablePosition.value = [event.pageX, event.pageY];
window.addEventListener('mousemove', onDrag);
window.addEventListener('mouseup', onDragEnd);
// blur so that any focused inputs update value
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
};
const onDrag = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.disabled) {
return;
}
if (!isDragging.value && draggingElement.value) {
if (dragStartPosition.value && props.dragThreshold > 0) {
const dx = event.pageX - dragStartPosition.value[0];
const dy = event.pageY - dragStartPosition.value[1];
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < props.dragThreshold) {
return;
}
}
isDragging.value = true;
const data = props.targetDataKey ? draggingElement.value.dataset.value : (props.data ?? '');
emit('dragstart', draggingElement.value, data);
document.body.style.cursor = 'grabbing';
}
animationFrameId.value = window.requestAnimationFrame(() => {
if (props.canDrop && props.stickyPosition) {
draggablePosition.value = props.stickyPosition;
} else {
draggablePosition.value = [event.pageX, event.pageY];
}
emit('drag', draggablePosition.value);
});
};
const onDragEnd = () => {
if (props.disabled) {
return;
}
document.body.style.cursor = 'unset';
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', onDragEnd);
if (isPresent(animationFrameId.value)) {
window.cancelAnimationFrame(animationFrameId.value);
}
setTimeout(() => {
if (isDragging.value && draggingElement.value) emit('dragend', draggingElement.value);
isDragging.value = false;
draggingElement.value = undefined;
dragStartPosition.value = null;
});
};
onBeforeUnmount(() => {
if (isDragging.value && draggingElement.value) {
emit('dragend', draggingElement.value);
}
});
</script>
<template>
<component
:is="tag"
ref="wrapper"
:class="{ [$style.dragging]: isDragging }"
data-test-id="draggable"
@mousedown="onDragStart"
>
<slot :is-dragging="isDragging"></slot>
<Teleport to="body">
<div v-show="isDragging" ref="draggable" :class="$style.draggable" :style="draggableStyle">
<slot name="preview" :can-drop="canDrop" :el="draggingElement"></slot>
</div>
</Teleport>
</component>
</template>
<style lang="scss" module>
.dragging {
visibility: visible;
cursor: grabbing;
}
.draggable {
pointer-events: none;
position: fixed;
z-index: 9999999;
top: 0;
left: 0;
}
.draggable-data-transfer {
width: 0;
height: 0;
}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import type { XYPosition } from '@/Interface';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { v4 as uuid } from 'uuid';
import { computed, ref, watch } from 'vue';
type Props = {
type: string;
disabled?: boolean;
sticky?: boolean;
stickyOffset?: XYPosition;
stickyOrigin?: 'top-left' | 'center';
};
const props = withDefaults(defineProps<Props>(), {
disabled: false,
sticky: false,
stickyOffset: () => [0, 0],
stickyOrigin: 'top-left',
});
const emit = defineEmits<{
drop: [value: string, event: MouseEvent];
}>();
const hovering = ref(false);
const targetRef = ref<HTMLElement>();
const id = ref(uuid());
const ndvStore = useNDVStore();
const isDragging = computed(() => ndvStore.isDraggableDragging);
const draggableType = computed(() => ndvStore.draggableType);
const draggableDimensions = computed(() => ndvStore.draggable.dimensions);
const droppable = computed(
() => !props.disabled && isDragging.value && draggableType.value === props.type,
);
const activeDrop = computed(() => droppable.value && hovering.value);
watch(activeDrop, (active) => {
if (active) {
const stickyPosition = getStickyPosition();
ndvStore.setDraggableTarget({ id: id.value, stickyPosition });
} else if (ndvStore.draggable.activeTarget?.id === id.value) {
// Only clear active target if it is this one
ndvStore.setDraggableTarget(null);
}
});
function onMouseEnter() {
hovering.value = true;
}
function onMouseLeave() {
hovering.value = false;
}
function onMouseUp(event: MouseEvent) {
if (activeDrop.value) {
const data = ndvStore.draggableData;
emit('drop', data, event);
}
}
function getStickyPosition(): XYPosition | null {
if (props.disabled || !props.sticky || !hovering.value || !targetRef.value) {
return null;
}
const { left, top, width, height } = targetRef.value.getBoundingClientRect();
if (props.stickyOrigin === 'center') {
return [
left + props.stickyOffset[0] + width / 2 - (draggableDimensions.value?.width ?? 0) / 2,
top + props.stickyOffset[1] + height / 2 - (draggableDimensions.value?.height ?? 0) / 2,
];
}
return [left + props.stickyOffset[0], top + props.stickyOffset[1]];
}
</script>
<template>
<div ref="targetRef" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mouseup="onMouseUp">
<slot :droppable="droppable" :active-drop="activeDrop"></slot>
</div>
</template>
@@ -0,0 +1,40 @@
import { createComponentRenderer } from '@/__tests__/render';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import DropArea from './DropArea.vue';
const renderComponent = createComponentRenderer(DropArea, {
pinia: createTestingPinia(),
});
async function fireDrop(dropArea: HTMLElement): Promise<void> {
useNDVStore().draggableStartDragging({
type: 'mapping',
data: '{{ $json.something }}',
dimensions: null,
});
await userEvent.hover(dropArea);
await fireEvent.mouseUp(dropArea);
}
describe('DropArea.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders default state correctly and emits drop events', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const { getByTestId, emitted } = renderComponent({ pinia });
expect(getByTestId('drop-area')).toBeInTheDocument();
await fireDrop(getByTestId('drop-area'));
expect(emitted('drop')).toEqual([['{{ $json.something }}']]);
});
});
@@ -0,0 +1,56 @@
<script setup lang="ts">
import DraggableTarget from '@/app/components/DraggableTarget.vue';
const emit = defineEmits<{ drop: [value: string] }>();
const onDrop = (value: string) => {
emit('drop', value);
};
</script>
<template>
<DraggableTarget type="mapping" @drop="onDrop">
<template #default="{ droppable, activeDrop }">
<div
data-test-id="drop-area"
:class="{ [$style.area]: true, [$style.active]: activeDrop, [$style.droppable]: droppable }"
>
<slot :active="activeDrop" :droppable="droppable"></slot>
</div>
</template>
</DraggableTarget>
</template>
<style lang="scss" module>
.area {
border: dashed 1px var(--color--foreground--shade-1);
border-radius: var(--radius--lg);
background: var(--color--background--light-2);
padding: var(--spacing--sm) var(--spacing--md);
display: flex;
align-items: baseline;
justify-content: center;
font-size: var(--font-size--sm);
transition: border-color 0.1s ease-in;
box-shadow: inset 0 0 0 1.5px var(--color--background--light-3);
&:not(.active):hover {
border-color: var(--ndv--droppable-parameter--color);
background: var(--ndv--droppable-parameter--color--background);
}
}
.droppable {
border-color: transparent;
outline: 1.5px dashed var(--ndv--droppable-parameter--color);
outline-offset: -1.5px;
background: var(--ndv--droppable-parameter--color--background);
}
.active {
border-color: var(--color--success);
border-width: 1px;
background: var(--ndv--droppable-parameter--color--background--active);
outline: none;
}
</style>
@@ -0,0 +1,224 @@
<script lang="ts" setup>
import { ref, watch, onMounted, nextTick } from 'vue';
import { MAX_WORKFLOW_NAME_LENGTH } from '@/app/constants';
import { useToast } from '@/app/composables/useToast';
import WorkflowTagsDropdown from '@/features/shared/tags/components/WorkflowTagsDropdown.vue';
import Modal from '@/app/components/Modal.vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows';
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import { N8nButton, N8nInput } from '@n8n/design-system';
const props = defineProps<{
modalName: string;
isActive: boolean;
data: {
tags: string[];
id: string;
name: string;
externalEventBus?: EventBus;
parentFolderId?: string;
};
}>();
const router = useRouter();
const workflowSaving = useWorkflowSaving({ router });
const workflowHelpers = useWorkflowHelpers();
const { showMessage, showError } = useToast();
const i18n = useI18n();
const telemetry = useTelemetry();
const credentialsStore = useCredentialsStore();
const settingsStore = useSettingsStore();
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const name = ref('');
const currentTagIds = ref(props.data.tags);
const isSaving = ref(false);
const prevTagIds = ref(currentTagIds.value);
const modalBus = createEventBus();
const dropdownBus = createEventBus();
const nameInputRef = ref<HTMLElement>();
const focusOnSelect = () => {
dropdownBus.emit('focus');
};
const focusOnNameInput = () => {
if (nameInputRef.value?.focus) {
nameInputRef.value.focus();
}
};
const onTagsBlur = () => {
prevTagIds.value = currentTagIds.value;
};
const onTagsEsc = () => {
currentTagIds.value = prevTagIds.value;
};
const closeDialog = () => {
modalBus.emit('close');
};
const save = async (): Promise<void> => {
const workflowName = name.value.trim();
if (!workflowName) {
showMessage({
title: i18n.baseText('duplicateWorkflowDialog.errors.missingName.title'),
message: i18n.baseText('duplicateWorkflowDialog.errors.missingName.message'),
type: 'error',
});
return;
}
const parentFolderId = props.data.parentFolderId;
const currentWorkflowId = props.data.id;
isSaving.value = true;
try {
let workflowToCreate: WorkflowDataCreate | undefined;
if (workflowsStore.isWorkflowSaved[props.data.id]) {
const {
createdAt,
updatedAt,
usedCredentials,
id,
homeProject,
sharedWithProjects,
activeVersionId,
activeVersion,
active,
...workflow
} = await workflowsListStore.fetchWorkflow(props.data.id);
workflowToCreate = { ...workflow, projectId: homeProject?.id };
workflowHelpers.removeForeignCredentialsFromWorkflow(
workflowToCreate,
credentialsStore.allCredentials,
);
}
const duplicatedWorkflowId = await workflowSaving.saveAsNewWorkflow({
name: workflowName,
data: workflowToCreate,
tags: currentTagIds.value,
resetWebhookUrls: true,
openInNewWindow: true,
resetNodeIds: true,
requestNewId: true,
parentFolderId,
});
if (duplicatedWorkflowId) {
closeDialog();
telemetry.track('User duplicated workflow', {
old_workflow_id: currentWorkflowId,
workflow_id: props.data.id,
sharing_role: workflowHelpers.getWorkflowProjectRole(props.data.id),
});
props.data.externalEventBus?.emit('workflow-duplicated', { id: props.data.id });
}
} catch (error) {
if (error.httpStatusCode === 403) {
error.message = i18n.baseText('duplicateWorkflowDialog.errors.forbidden.message');
showError(error, i18n.baseText('duplicateWorkflowDialog.errors.forbidden.title'));
} else {
showError(error, i18n.baseText('duplicateWorkflowDialog.errors.generic.title'));
}
} finally {
isSaving.value = false;
}
};
watch(
() => props.isActive,
(active) => {
if (active) {
focusOnSelect();
}
},
);
onMounted(async () => {
name.value = await workflowsStore.getDuplicateCurrentWorkflowName(props.data.name);
await nextTick();
focusOnNameInput();
});
</script>
<template>
<Modal
:name="modalName"
:event-bus="modalBus"
:title="i18n.baseText('duplicateWorkflowDialog.duplicateWorkflow')"
:center="true"
width="420px"
@enter="save"
>
<template #content>
<div :class="$style.content">
<N8nInput
ref="nameInputRef"
v-model="name"
:placeholder="i18n.baseText('duplicateWorkflowDialog.enterWorkflowName')"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
<WorkflowTagsDropdown
v-if="settingsStore.areTagsEnabled"
ref="dropdown"
v-model="currentTagIds"
:create-enabled="true"
:event-bus="dropdownBus"
:placeholder="i18n.baseText('duplicateWorkflowDialog.chooseOrCreateATag')"
@blur="onTagsBlur"
@esc="onTagsEsc"
/>
</div>
</template>
<template #footer="{ close }">
<div :class="$style.footer">
<N8nButton
variant="subtle"
:disabled="isSaving"
:label="i18n.baseText('duplicateWorkflowDialog.cancel')"
@click="close"
/>
<N8nButton
:loading="isSaving"
:label="i18n.baseText('duplicateWorkflowDialog.save')"
@click="save"
/>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
> *:not(:last-child) {
margin-bottom: var(--spacing--md);
}
}
.footer {
display: flex;
justify-content: flex-end;
> * {
margin-left: var(--spacing--3xs);
}
}
</style>
@@ -0,0 +1,307 @@
import { createTestingPinia } from '@pinia/testing';
import { defineComponent, nextTick } from 'vue';
import { screen } from '@testing-library/vue';
import DynamicModalLoader from '@/app/components/DynamicModalLoader.vue';
import * as modalRegistry from '@/app/moduleInitializer/modalRegistry';
import type { ModalDefinition } from '@/app/moduleInitializer/module.types';
import { createComponentRenderer } from '@/__tests__/render';
// Mock the modalRegistry module
vi.mock('@/app/moduleInitializer/modalRegistry', () => ({
getAll: vi.fn(),
subscribe: vi.fn(),
}));
const mockModalRegistry = vi.mocked(modalRegistry);
const renderComponent = createComponentRenderer(DynamicModalLoader, {
global: {
stubs: {
ModalRoot: {
props: ['name', 'keepAlive'],
template: `
<div data-testid="modal-root" :data-modal-name="name">
<slot
:modalName="name"
:active="true"
:open="true"
:activeId="'test-id'"
:mode="'edit'"
:data="{ test: 'value' }"
/>
</div>
`,
},
},
},
});
describe('DynamicModalLoader', () => {
const mockModalComponent = defineComponent({
name: 'MockModal',
props: {
modalName: { type: String, required: true },
active: { type: Boolean, required: true },
open: { type: Boolean, required: true },
activeId: { type: String, required: true },
mode: { type: String, required: true },
data: { type: Object, required: true },
},
template: '<div data-testid="mock-modal" :data-modal-name="modalName">Mock Modal</div>',
});
const mockAsyncModalComponent = vi.fn(async () => await Promise.resolve(mockModalComponent));
beforeEach(() => {
vi.clearAllMocks();
});
it('should render empty div when no modals are registered', () => {
mockModalRegistry.getAll.mockReturnValue(new Map());
mockModalRegistry.subscribe.mockReturnValue(vi.fn());
const { container } = renderComponent({
pinia: createTestingPinia(),
});
expect(container.firstChild).toBeInTheDocument();
expect(screen.queryByTestId('modal-root')).not.toBeInTheDocument();
});
it('should render ModalRoot components for registered modals', async () => {
const modalsMap = new Map<string, ModalDefinition>([
[
'testModal1',
{
key: 'testModal1',
component: mockModalComponent,
},
],
[
'testModal2',
{
key: 'testModal2',
component: mockModalComponent,
},
],
]);
mockModalRegistry.getAll.mockReturnValue(modalsMap);
mockModalRegistry.subscribe.mockReturnValue(vi.fn());
const { container } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
const modalRoots = container.querySelectorAll('[data-testid="modal-root"]');
expect(modalRoots).toHaveLength(2);
const modalNames = Array.from(modalRoots).map((root) => root.getAttribute('data-modal-name'));
expect(modalNames).toContain('testModal1');
expect(modalNames).toContain('testModal2');
});
it('should handle async component factories', async () => {
const modalsMap = new Map<string, ModalDefinition>([
[
'asyncModal',
{
key: 'asyncModal',
component: mockAsyncModalComponent,
},
],
]);
mockModalRegistry.getAll.mockReturnValue(modalsMap);
mockModalRegistry.subscribe.mockReturnValue(vi.fn());
const { container } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
const modalRoot = container.querySelector('[data-testid="modal-root"]');
expect(modalRoot).toBeInTheDocument();
expect(modalRoot?.getAttribute('data-modal-name')).toBe('asyncModal');
expect(mockAsyncModalComponent).toHaveBeenCalled();
});
it('should subscribe to modalRegistry changes on mount', () => {
mockModalRegistry.getAll.mockReturnValue(new Map());
const mockUnsubscribe = vi.fn();
mockModalRegistry.subscribe.mockReturnValue(mockUnsubscribe);
renderComponent({
pinia: createTestingPinia(),
});
expect(mockModalRegistry.subscribe).toHaveBeenCalledTimes(1);
expect(mockModalRegistry.subscribe).toHaveBeenCalledWith(expect.any(Function));
});
it('should update modals when registry changes', async () => {
let subscribeCallback: ((modals: Map<string, ModalDefinition>) => void) | undefined;
// Initial empty registry
mockModalRegistry.getAll.mockReturnValue(new Map());
mockModalRegistry.subscribe.mockImplementation((listener) => {
subscribeCallback = listener;
return vi.fn();
});
const { container } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
// Initially no modal roots
expect(container.querySelector('[data-testid="modal-root"]')).not.toBeInTheDocument();
// Simulate registry change
const newModalsMap = new Map<string, ModalDefinition>([
[
'newModal',
{
key: 'newModal',
component: mockModalComponent,
},
],
]);
mockModalRegistry.getAll.mockReturnValue(newModalsMap);
subscribeCallback?.(newModalsMap);
await nextTick();
// Should now have one modal root
const modalRoot = container.querySelector('[data-testid="modal-root"]');
expect(modalRoot).toBeInTheDocument();
expect(modalRoot?.getAttribute('data-modal-name')).toBe('newModal');
});
it('should unsubscribe from registry changes on unmount', () => {
mockModalRegistry.getAll.mockReturnValue(new Map());
const mockUnsubscribe = vi.fn();
mockModalRegistry.subscribe.mockReturnValue(mockUnsubscribe);
const wrapper = renderComponent({
pinia: createTestingPinia(),
});
expect(mockUnsubscribe).not.toHaveBeenCalled();
wrapper.unmount();
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
});
it('should handle modals with different component types', async () => {
const regularComponent = defineComponent({
name: 'RegularModal',
props: {
modalName: { type: String, required: true },
active: { type: Boolean, required: true },
open: { type: Boolean, required: true },
activeId: { type: String, required: true },
mode: { type: String, required: true },
data: { type: Object, required: true },
},
template: '<div data-testid="regular-modal">Regular</div>',
});
const asyncComponent = async () =>
await Promise.resolve(
defineComponent({
name: 'AsyncModal',
props: {
modalName: { type: String, required: true },
active: { type: Boolean, required: true },
open: { type: Boolean, required: true },
activeId: { type: String, required: true },
mode: { type: String, required: true },
data: { type: Object, required: true },
},
template: '<div data-testid="async-modal">Async</div>',
}),
);
const modalsMap = new Map<string, ModalDefinition>([
['regular', { key: 'regular', component: regularComponent }],
['async', { key: 'async', component: asyncComponent }],
]);
mockModalRegistry.getAll.mockReturnValue(modalsMap);
mockModalRegistry.subscribe.mockReturnValue(vi.fn());
const { container } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
const modalRoots = container.querySelectorAll('[data-testid="modal-root"]');
expect(modalRoots).toHaveLength(2);
// Both modals should be rendered
const modalNames = Array.from(modalRoots).map((root) => root.getAttribute('data-modal-name'));
expect(modalNames).toContain('regular');
expect(modalNames).toContain('async');
});
it('should call updateModals on mount', () => {
const modalsMap = new Map<string, ModalDefinition>([
['testModal', { key: 'testModal', component: mockModalComponent }],
]);
mockModalRegistry.getAll.mockReturnValue(modalsMap);
mockModalRegistry.subscribe.mockReturnValue(vi.fn());
renderComponent({
pinia: createTestingPinia(),
});
// getAll should be called during updateModals on mount
expect(mockModalRegistry.getAll).toHaveBeenCalledTimes(1);
});
it('should correctly identify async component factories', async () => {
// Test with regular component (not async)
const regularComponent = defineComponent({
name: 'RegularComponent',
props: {
modalName: { type: String, required: true },
active: { type: Boolean, required: true },
open: { type: Boolean, required: true },
activeId: { type: String, required: true },
mode: { type: String, required: true },
data: { type: Object, required: true },
},
template: '<div>Regular</div>',
});
// Test with async component factory
const asyncFactory = async () => await Promise.resolve(regularComponent);
const modalsMap = new Map<string, ModalDefinition>([
['regularModal', { key: 'regularModal', component: regularComponent }],
['asyncModal', { key: 'asyncModal', component: asyncFactory }],
]);
mockModalRegistry.getAll.mockReturnValue(modalsMap);
mockModalRegistry.subscribe.mockReturnValue(vi.fn());
const { container } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
// Both should render successfully
const modalRoots = container.querySelectorAll('[data-testid="modal-root"]');
expect(modalRoots).toHaveLength(2);
});
});
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { onMounted, onUnmounted, defineAsyncComponent, shallowRef } from 'vue';
import type { Component } from 'vue';
import * as modalRegistry from '@/app/moduleInitializer/modalRegistry';
import ModalRoot from '@/app/components/ModalRoot.vue';
// Keep track of registered modals
// Using shallowRef() to fix "Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead..."
const registeredModals = shallowRef<
Array<{
key: string;
component: Component;
}>
>([]);
// Type guard to check if component is an async component factory
const isAsyncComponentFactory = (
component: Component | (() => Promise<Component>),
): component is () => Promise<Component> => {
return typeof component === 'function';
};
const updateModals = () => {
const modals: Array<{
key: string;
component: Component;
}> = [];
modalRegistry.getAll().forEach((modalDef, key) => {
// Create async component wrapper if it's a function
const component = isAsyncComponentFactory(modalDef.component)
? defineAsyncComponent(modalDef.component)
: modalDef.component;
modals.push({ key, component });
});
registeredModals.value = modals;
};
// Subscribe to registry changes
let unsubscribe: (() => void) | undefined;
onMounted(() => {
updateModals(); // Initial load
unsubscribe = modalRegistry.subscribe(() => {
updateModals();
});
});
onUnmounted(() => {
unsubscribe?.();
});
</script>
<template>
<div>
<template v-for="modal in registeredModals" :key="modal.key">
<ModalRoot :name="modal.key">
<template #default="{ modalName, active, open, activeId, mode, data }">
<component
:is="modal.component"
:modal-name="modalName"
:active="active"
:open="open"
:active-id="activeId"
:mode="mode"
:data="data"
/>
</template>
</ModalRoot>
</template>
</div>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { EnterpriseEditionFeatureValue } from '@/Interface';
import { useSettingsStore } from '@/app/stores/settings.store';
const props = withDefaults(
defineProps<{
features: EnterpriseEditionFeatureValue[];
}>(),
{
features: () => [],
},
);
const settingsStore = useSettingsStore();
const canAccess = computed(() =>
props.features.reduce(
(acc: boolean, feature) => acc && !!settingsStore.isEnterpriseFeatureEnabled[feature],
true,
),
);
</script>
<template>
<div>
<slot v-if="canAccess" />
<slot v-else name="fallback" />
</div>
</template>
@@ -0,0 +1,48 @@
import { render, fireEvent } from '@testing-library/vue';
import Feedback from '@/app/components/Feedback.vue';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
describe('Feedback', () => {
it('should emit update:modelValue event with positive feedback', async () => {
const { getByTestId, emitted } = render(Feedback);
await fireEvent.click(getByTestId('feedback-button-positive'));
expect(emitted()).toHaveProperty('update:modelValue');
expect(emitted()['update:modelValue'][0]).toEqual(['positive']);
});
it('should emit update:modelValue event with negative feedback', async () => {
const { getByTestId, emitted } = render(Feedback);
await fireEvent.click(getByTestId('feedback-button-negative'));
expect(emitted()).toHaveProperty('update:modelValue');
expect(emitted()['update:modelValue'][0]).toEqual(['negative']);
});
it('should display positive feedback message when modelValue is positive', () => {
const { getByText } = render(Feedback, {
props: {
modelValue: 'positive',
},
});
expect(getByText(/feedback.positive/i)).toBeInTheDocument();
});
it('should display negative feedback message when modelValue is negative', () => {
const { getByText } = render(Feedback, {
props: {
modelValue: 'negative',
},
});
expect(getByText(/feedback.negative/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
const emit = defineEmits<{
'update:modelValue': [feedback: 'positive' | 'negative'];
}>();
defineProps<{
modelValue?: 'positive' | 'negative';
}>();
const i18n = useI18n();
function onFeedback(feedback: 'positive' | 'negative') {
emit('update:modelValue', feedback);
}
</script>
<template>
<div class="feedback">
<N8nText v-if="!modelValue" class="mr-2xs">
{{ i18n.baseText('feedback.title') }}
</N8nText>
<N8nText v-else :color="modelValue === 'positive' ? 'success' : 'danger'">
<N8nIcon :icon="modelValue === 'positive' ? 'thumbs-up' : 'thumbs-down'" class="mr-2xs" />
{{ i18n.baseText(`feedback.${modelValue}`) }}
</N8nText>
<N8nTooltip v-if="!modelValue" :content="i18n.baseText('feedback.positive')">
<span
class="feedback-button"
data-test-id="feedback-button-positive"
@click="onFeedback('positive')"
>
<N8nIcon icon="thumbs-up" />
</span>
</N8nTooltip>
<N8nTooltip v-if="!modelValue" :content="i18n.baseText('feedback.negative')">
<span
class="feedback-button"
data-test-id="feedback-button-negative"
@click="onFeedback('negative')"
>
<N8nIcon icon="thumbs-down" />
</span>
</N8nTooltip>
</div>
</template>
<style lang="scss">
.feedback {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
.feedback-button {
cursor: pointer;
width: var(--spacing--lg);
height: var(--spacing--lg);
color: var(--color--text--tint-1);
display: flex;
justify-content: center;
align-items: center;
&:hover {
color: var(--color--primary);
}
}
}
</style>
@@ -0,0 +1,134 @@
import { createCanvasGraphNode } from '@/features/workflows/canvas/__tests__/utils';
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { SET_NODE_TYPE } from '@/app/constants';
import { useFocusPanelStore } from '@/app/stores/focusPanel.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import { useVueFlow } from '@vue-flow/core';
import type { INodeProperties } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import { reactive } from 'vue';
import { useExperimentalNdvStore } from '@/features/workflows/canvas/experimental/experimentalNdv.store';
import FocusPanel from './FocusPanel.vue';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
useRoute: () => reactive({}),
RouterLink: vi.fn(),
}));
describe('FocusPanel', () => {
const renderComponent = createComponentRenderer(FocusPanel, {
props: {
isCanvasReadOnly: false,
},
});
const parameter0: INodeProperties = {
displayName: 'P0',
name: 'p0',
type: 'string',
default: '',
description: '',
validateType: 'string',
};
const parameter1: INodeProperties = {
displayName: 'P1',
name: 'p1',
type: 'string',
default: '',
description: '',
validateType: 'string',
};
let experimentalNdvStore: ReturnType<typeof mockedStore<typeof useExperimentalNdvStore>>;
let focusPanelStore: ReturnType<typeof useFocusPanelStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
beforeEach(() => {
const pinia = setActivePinia(createTestingPinia({ stubActions: false }));
localStorage.clear();
nodeTypesStore = useNodeTypesStore(pinia);
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: SET_NODE_TYPE,
properties: [parameter0, parameter1],
}),
]);
workflowsStore = useWorkflowsStore(pinia);
workflowsStore.setWorkflow(createTestWorkflow({ id: 'w0' }));
workflowsStore.setNodes([
createTestNode({ id: 'n0', name: 'N0', parameters: { p0: 'v0' }, type: SET_NODE_TYPE }),
]);
focusPanelStore = useFocusPanelStore(pinia);
focusPanelStore.toggleFocusPanel();
});
describe('when experimental NDV is enabled', () => {
beforeEach(() => {
experimentalNdvStore = mockedStore(useExperimentalNdvStore);
experimentalNdvStore.isNdvInFocusPanelEnabled = true;
});
it('should render empty state when neither a node nor a parameter is selected', async () => {
const rendered = renderComponent({});
expect(await rendered.findByText('Select a node to edit it here'));
});
it('should render the parameter focus input when a parameter is selected', async () => {
const rendered = renderComponent({});
focusPanelStore.openWithFocusedNodeParameter({
nodeId: 'n0',
parameter: parameter0,
parameterPath: 'parameters.p0',
});
expect(await rendered.findByTestId('focus-parameter')).toBeInTheDocument();
expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header
expect(rendered.getByText('P0')).toBeInTheDocument(); // title in header
expect(rendered.getByDisplayValue('v0')).toBeInTheDocument(); // current value of the parameter
});
it('should render node parameters when a node is selected on canvas', async () => {
const graphNode = createCanvasGraphNode({ id: 'n0' });
const vueFlow = useVueFlow('w0');
const rendered = renderComponent({});
vueFlow.addNodes([graphNode]);
vueFlow.addSelectedNodes([graphNode]);
expect(await rendered.findByTestId('node-parameters')).toBeInTheDocument();
expect(rendered.getAllByText('N0')).not.toHaveLength(0); // title in header
expect(await rendered.findByText('P0')).toBeInTheDocument(); // parameter 0
expect(await rendered.findByText('P1')).toBeInTheDocument(); // parameter 1
});
it('should render the parameters when a node is selected on canvas and a parameter is selected', async () => {
const graphNode = createCanvasGraphNode({ id: 'n0' });
const vueFlow = useVueFlow('w0');
const rendered = renderComponent({});
vueFlow.addNodes([graphNode]);
vueFlow.addSelectedNodes([graphNode]);
focusPanelStore.openWithFocusedNodeParameter({
nodeId: 'n0',
parameter: parameter0,
parameterPath: 'parameters.p0',
});
expect(await rendered.findByTestId('focus-parameter')).toBeInTheDocument();
expect(rendered.getByText('N0')).toBeInTheDocument(); // title in header
expect(rendered.getByText('P0')).toBeInTheDocument(); // title in header
expect(rendered.getByDisplayValue('v0')).toBeInTheDocument(); // current value of the parameter
});
});
});
@@ -0,0 +1,733 @@
<script setup lang="ts">
import CodeNodeEditor from '@/features/shared/editors/components/CodeNodeEditor/CodeNodeEditor.vue';
import CssEditor from '@/features/shared/editors/components/CssEditor/CssEditor.vue';
import ExpressionEditorModalInput from '@/features/ndv/parameters/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
import HtmlEditor from '@/features/shared/editors/components/HtmlEditor/HtmlEditor.vue';
import JsEditor from '@/features/shared/editors/components/JsEditor/JsEditor.vue';
import JsonEditor from '@/features/shared/editors/components/JsonEditor/JsonEditor.vue';
import NodeExecuteButton from '@/app/components/NodeExecuteButton.vue';
import ParameterOptions from '@/features/ndv/parameters/components/ParameterOptions.vue';
import SqlEditor from '@/features/shared/editors/components/SqlEditor/SqlEditor.vue';
import { useFocusPanelStore } from '@/app/stores/focusPanel.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { computed, nextTick, ref, watch, toRef } from 'vue';
import { useI18n } from '@n8n/i18n';
import {
formatAsExpression,
getParameterTypeOption,
isValidParameterOption,
parseFromExpression,
} from '@/features/ndv/shared/ndv.utils';
import { isValueExpression } from '@/app/utils/nodeTypesUtils';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { useNodeSettingsParameters } from '@/features/ndv/settings/composables/useNodeSettingsParameters';
import { useResolvedExpression } from '@/app/composables/useResolvedExpression';
import {
AI_TRANSFORM_NODE_TYPE,
type CodeExecutionMode,
type CodeNodeEditorLanguage,
type EditorType,
HTML_NODE_TYPE,
type INodeProperties,
isResourceLocatorValue,
} from 'n8n-workflow';
import { useEnvironmentsStore } from '@/features/settings/environments.ee/environments.store';
import { htmlEditorEventBus } from '@/app/event-bus';
import { hasFocusOnInput, isFocusableEl } from '@/app/utils/typesUtils';
import type { INodeUi, TargetNodeParameterContext } from '@/Interface';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { computedAsync } from '@vueuse/core';
import { useExecutionData } from '@/features/execution/executions/composables/useExecutionData';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import ExperimentalNodeDetailsDrawer from '@/features/workflows/canvas/experimental/components/ExperimentalNodeDetailsDrawer.vue';
import { useExperimentalNdvStore } from '@/features/workflows/canvas/experimental/experimentalNdv.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useVueFlow } from '@vue-flow/core';
import ExperimentalFocusPanelHeader from '@/features/workflows/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
import { type ContextMenuAction } from '@/features/shared/contextMenu/composables/useContextMenuItems';
import { type CanvasNode, CanvasNodeRenderType } from '@/features/workflows/canvas/canvas.types';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import { N8nIcon, N8nInfoTip, N8nInput, N8nRadioButtons, N8nText } from '@n8n/design-system';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
defineOptions({ name: 'FocusPanel' });
const props = defineProps<{
isCanvasReadOnly: boolean;
}>();
const emit = defineEmits<{
focus: [];
saveKeyboardShortcut: [event: KeyboardEvent];
contextMenuAction: [action: ContextMenuAction, nodeIds: string[]];
}>();
// ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputField = ref<InstanceType<typeof N8nInput> | HTMLElement>();
const locale = useI18n();
const nodeHelpers = useNodeHelpers();
const focusPanelStore = useFocusPanelStore();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const nodeTypesStore = useNodeTypesStore();
const telemetry = useTelemetry();
const nodeSettingsParameters = useNodeSettingsParameters();
const environmentsStore = useEnvironmentsStore();
const experimentalNdvStore = useExperimentalNdvStore();
const ndvStore = useNDVStore();
const vueFlow = useVueFlow(workflowsStore.workflowId);
const { renameNode } = useCanvasOperations();
const resolvedParameter = computed(() => focusPanelStore.resolvedParameter);
const inputValue = ref<string>('');
const isDisabled = computedAsync(async () => {
if (!resolvedParameter.value) {
return false;
}
const parentPath = resolvedParameter.value.parameterPath.split('.').slice(1, -1).join('.');
// shouldDisplayNodeParameter returns true if disabledOptions exists and matches, OR if disabledOptions doesn't exist
return (
!!resolvedParameter.value.parameter.disabledOptions &&
(await nodeSettingsParameters.shouldDisplayNodeParameter(
resolvedParameter.value.node.parameters,
resolvedParameter.value.node,
resolvedParameter.value.parameter,
parentPath,
'disabledOptions',
))
);
}, false);
const isDisplayed = computedAsync(async () => {
if (!resolvedParameter.value) {
return true;
}
const parentPath = resolvedParameter.value.parameterPath.split('.').slice(1, -1).join('.');
return await nodeSettingsParameters.shouldDisplayNodeParameter(
resolvedParameter.value.node.parameters,
resolvedParameter.value.node,
resolvedParameter.value.parameter,
parentPath,
'displayOptions',
);
}, true);
const node = computed<INodeUi | undefined>(() => {
if (!experimentalNdvStore.isNdvInFocusPanelEnabled || resolvedParameter.value) {
return resolvedParameter.value?.node;
}
const selected: CanvasNode | undefined = vueFlow.getSelectedNodes.value[0];
return selected?.data?.render.type === CanvasNodeRenderType.Default
? workflowsStore.allNodes.find((n) => n.id === selected.id)
: undefined;
});
const multipleNodesSelected = computed(() => vueFlow.getSelectedNodes.value.length > 1);
const isExecutable = computed(() => {
if (!node.value) return false;
if (!isDisplayed.value) return false;
const foreignCredentials = nodeHelpers.getForeignCredentialsIfSharingEnabled(
node.value.credentials,
);
return nodeHelpers.isNodeExecutable(node.value, !props.isCanvasReadOnly, foreignCredentials);
});
const { workflowRunData } = useExecutionData({ node });
const hasNodeRun = computed(() => {
if (!node.value) return true;
const parentNode = workflowsStore.workflowObject.getParentNodes(node.value.name, 'main', 1)[0];
return Boolean(
parentNode &&
workflowRunData.value &&
Object.prototype.hasOwnProperty.bind(workflowRunData.value)(parentNode),
);
});
function getTypeOption<T extends keyof NonNullable<INodeProperties['typeOptions']>>(optionName: T) {
return resolvedParameter.value
? getParameterTypeOption(resolvedParameter.value.parameter, optionName)
: undefined;
}
const codeEditorMode = computed<CodeExecutionMode>(() => {
return resolvedParameter.value?.node.parameters.mode as CodeExecutionMode;
});
const editorType = computed<EditorType | 'json' | 'code' | 'cssEditor' | undefined>(() => {
return getTypeOption('editor') ?? undefined;
});
const editorLanguage = computed<CodeNodeEditorLanguage>(() => {
if (editorType.value === 'json' || resolvedParameter.value?.parameter.type === 'json')
return 'json' as CodeNodeEditorLanguage;
return getTypeOption('editorLanguage') ?? 'javaScript';
});
const editorRows = computed(() => getTypeOption('rows'));
const isToolNode = computed(() =>
resolvedParameter.value ? nodeTypesStore.isToolNode(resolvedParameter.value?.node.type) : false,
);
const isHtmlNode = computed(
() => !!resolvedParameter.value && resolvedParameter.value.node.type === HTML_NODE_TYPE,
);
const expressionModeEnabled = computed(
() =>
resolvedParameter.value &&
isValueExpression(resolvedParameter.value.parameter, resolvedParameter.value.value),
);
const expression = computed(() => {
if (!expressionModeEnabled.value) return '';
return isResourceLocatorValue(resolvedParameter.value)
? resolvedParameter.value.value
: resolvedParameter.value;
});
const shouldCaptureForPosthog = computed(
() => resolvedParameter.value?.node.type === AI_TRANSFORM_NODE_TYPE,
);
const isReadOnly = computed(() => props.isCanvasReadOnly || isDisabled.value);
const resolvedAdditionalExpressionData = computed(() => {
return {
$vars: environmentsStore.variablesAsObject,
};
});
const targetNodeParameterContext = computed<TargetNodeParameterContext | undefined>(() => {
if (!resolvedParameter.value) return undefined;
return {
nodeName: resolvedParameter.value.node.name,
parameterPath: resolvedParameter.value.parameterPath,
};
});
const isNodeExecuting = computed(() =>
workflowState.executingNode.isNodeExecuting(node.value?.name ?? ''),
);
const selectedNodeIds = computed(() => vueFlow.getSelectedNodes.value.map((n) => n.id));
const emptyTitle = computed(() =>
experimentalNdvStore.isNdvInFocusPanelEnabled
? locale.baseText('nodeView.focusPanel.v2.noParameters.title')
: locale.baseText('nodeView.focusPanel.noParameters.title'),
);
const emptySubtitle = computed(() =>
experimentalNdvStore.isNdvInFocusPanelEnabled
? locale.baseText('nodeView.focusPanel.v2.noParameters.subtitle')
: locale.baseText('nodeView.focusPanel.noParameters.subtitle'),
);
const { resolvedExpression } = useResolvedExpression({
expression,
additionalData: resolvedAdditionalExpressionData,
stringifyObject:
resolvedParameter.value && resolvedParameter.value.parameter.type !== 'multiOptions',
});
function valueChanged(value: string) {
if (resolvedParameter.value === undefined) {
return;
}
nodeSettingsParameters.updateNodeParameter(
toRef(resolvedParameter.value.node.parameters),
{ value, name: resolvedParameter.value.parameterPath as `parameters.${string}` },
value,
resolvedParameter.value.node,
isToolNode.value,
);
}
async function setFocus() {
await nextTick();
if (inputField.value) {
if (hasFocusOnInput(inputField.value)) {
inputField.value.focusOnInput();
} else if (isFocusableEl(inputField.value)) {
inputField.value.focus();
}
}
emit('focus');
}
function optionSelected(command: string) {
if (!resolvedParameter.value) return;
switch (command) {
case 'resetValue': {
if (typeof resolvedParameter.value.parameter.default === 'string') {
valueChanged(resolvedParameter.value.parameter.default);
}
void setFocus();
break;
}
case 'addExpression': {
const newValue = formatAsExpression(
resolvedParameter.value.value,
resolvedParameter.value.parameter.type,
);
valueChanged(typeof newValue === 'string' ? newValue : newValue.value);
void setFocus();
break;
}
case 'removeExpression': {
const newValue = parseFromExpression(
resolvedParameter.value.value,
resolvedExpression.value,
resolvedParameter.value.parameter.type,
resolvedParameter.value.parameter.default,
(resolvedParameter.value.parameter.options ?? []).filter(isValidParameterOption),
);
if (typeof newValue === 'string') {
valueChanged(newValue);
} else if (newValue && typeof (newValue as { value?: unknown }).value === 'string') {
valueChanged((newValue as { value: string }).value);
}
void setFocus();
break;
}
case 'formatHtml':
htmlEditorEventBus.emit('format-html');
break;
}
}
function closeFocusPanel() {
if (experimentalNdvStore.isNdvInFocusPanelEnabled && resolvedParameter.value) {
focusPanelStore.unsetParameters();
telemetry.track('User removed focused param', {
source: 'closeIcon',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
return;
}
telemetry.track('User closed focus panel', {
source: 'closeIcon',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
focusPanelStore.closeFocusPanel();
}
function onExecute() {
telemetry.track(
'User executed node from focus panel',
focusPanelStore.focusedNodeParametersInTelemetryFormat[0],
);
}
function onInputChange(val: string) {
inputValue.value = val;
valueChanged(val);
}
// Wait for editor to mount before focusing
function focusWithDelay() {
setTimeout(() => {
void setFocus();
}, 50);
}
watch([() => focusPanelStore.lastFocusTimestamp, () => expressionModeEnabled.value], () =>
focusWithDelay(),
);
watch(
() => resolvedParameter.value,
(newValue) => {
if (newValue) {
const value = newValue.value;
if (typeof value === 'string' && value !== inputValue.value) {
inputValue.value = value;
}
}
},
{ immediate: true },
);
function onOpenNdv() {
if (node.value) {
ndvStore.setActiveNodeName(node.value.name, 'focus_panel');
}
}
function onRenameNode(value: string) {
if (node.value) {
void renameNode(node.value.name, value);
}
}
</script>
<template>
<div
data-test-id="focus-panel"
:class="[
$style.content,
{ [$style.isNdvInFocusPanelEnabled]: experimentalNdvStore.isNdvInFocusPanelEnabled },
]"
>
<ExperimentalFocusPanelHeader
v-if="experimentalNdvStore.isNdvInFocusPanelEnabled && node && !multipleNodesSelected"
:node="node"
:parameter="resolvedParameter?.parameter"
:is-executable="isExecutable"
:read-only="isCanvasReadOnly"
@execute="onExecute"
@open-ndv="onOpenNdv"
@clear-parameter="closeFocusPanel"
@rename-node="onRenameNode"
/>
<div v-if="resolvedParameter" :class="$style.content" data-test-id="focus-parameter">
<div v-if="!experimentalNdvStore.isNdvInFocusPanelEnabled" :class="$style.tabHeader">
<div :class="$style.tabHeaderText">
<N8nText color="text-dark" size="small">
{{ resolvedParameter.parameter.displayName }}
</N8nText>
<N8nText color="text-base" size="xsmall">{{ resolvedParameter.node.name }}</N8nText>
</div>
<div :class="$style.buttonWrapper">
<NodeExecuteButton
data-test-id="node-execute-button"
:node-name="resolvedParameter.node.name"
:tooltip="`Execute ${resolvedParameter.node.name}`"
:disabled="!isExecutable"
size="small"
icon="play"
:square="true"
:hide-label="true"
telemetry-source="focus"
@execute="onExecute"
/>
<N8nIcon
:class="$style.closeButton"
icon="x"
color="text-base"
size="xlarge"
@click="closeFocusPanel"
/>
</div>
</div>
<div :class="$style.parameterDetailsWrapper">
<div :class="$style.parameterOptionsWrapper">
<div :class="$style.noExecutionDataTip">
<N8nInfoTip
v-if="!hasNodeRun && !isNodeExecuting"
:class="$style.delayedShow"
:bold="true"
>
{{ locale.baseText('nodeView.focusPanel.noExecutionData') }}
</N8nInfoTip>
</div>
<ParameterOptions
v-if="isDisplayed"
:parameter="resolvedParameter.parameter"
:value="resolvedParameter.value"
:is-read-only="isReadOnly"
@update:model-value="optionSelected"
/>
</div>
<div v-if="typeof resolvedParameter.value === 'string'" :class="$style.editorContainer">
<div v-if="!isDisplayed" :class="[$style.content, $style.emptyContent]">
<div :class="$style.emptyText">
<N8nText color="text-base">
{{ locale.baseText('nodeView.focusPanel.missingParameter') }}
</N8nText>
</div>
</div>
<ExpressionEditorModalInput
v-else-if="expressionModeEnabled"
ref="inputField"
v-model="inputValue"
:class="$style.editor"
:is-read-only="isReadOnly"
:path="resolvedParameter.parameterPath"
data-test-id="expression-modal-input"
:target-node-parameter-context="targetNodeParameterContext"
@change="onInputChange($event.value)"
/>
<template v-else-if="['json', 'string'].includes(resolvedParameter.parameter.type)">
<CodeNodeEditor
v-if="editorType === 'codeNodeEditor'"
:id="resolvedParameter.parameterPath"
ref="inputField"
v-model="inputValue"
:class="$style.heightFull"
:mode="codeEditorMode"
:default-value="resolvedParameter.parameter.default"
:language="editorLanguage"
:is-read-only="isReadOnly"
:target-node-parameter-context="targetNodeParameterContext"
fill-parent
:disable-ask-ai="true"
@update:model-value="onInputChange"
/>
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
ref="inputField"
v-model="inputValue"
:is-read-only="isReadOnly"
:rows="editorRows"
:disable-expression-coloring="!isHtmlNode"
:disable-expression-completions="!isHtmlNode"
fullscreen
:target-node-parameter-context="targetNodeParameterContext"
@update:model-value="onInputChange"
/>
<CssEditor
v-else-if="editorType === 'cssEditor'"
ref="inputField"
v-model="inputValue"
:is-read-only="isReadOnly"
:rows="editorRows"
fullscreen
:target-node-parameter-context="targetNodeParameterContext"
@update:model-value="onInputChange"
/>
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
ref="inputField"
v-model="inputValue"
:dialect="getTypeOption('sqlDialect')"
:is-read-only="isReadOnly"
:rows="editorRows"
fullscreen
:target-node-parameter-context="targetNodeParameterContext"
@update:model-value="onInputChange"
/>
<JsEditor
v-else-if="editorType === 'jsEditor'"
ref="inputField"
v-model="inputValue"
:is-read-only="isReadOnly"
:rows="editorRows"
:posthog-capture="shouldCaptureForPosthog"
fill-parent
@update:model-value="onInputChange"
/>
<JsonEditor
v-else-if="resolvedParameter.parameter.type === 'json'"
ref="inputField"
v-model="inputValue"
:is-read-only="isReadOnly"
:rows="editorRows"
fullscreen
fill-parent
@update:model-value="onInputChange"
/>
<N8nInput
v-else
ref="inputField"
v-model="inputValue"
:class="$style.editor"
:readonly="isReadOnly"
type="textarea"
resize="none"
@update:model-value="onInputChange"
/>
</template>
</div>
</div>
</div>
<ExperimentalNodeDetailsDrawer
v-else-if="node && experimentalNdvStore.isNdvInFocusPanelEnabled"
:node="node"
:node-ids="selectedNodeIds"
:is-read-only="isReadOnly"
@open-ndv="onOpenNdv"
@context-menu-action="(action, nodeIds) => emit('contextMenuAction', action, nodeIds)"
/>
<div v-else :class="[$style.content, $style.emptyContent]">
<div :class="$style.focusParameterWrapper">
<div :class="$style.iconWrapper">
<N8nIcon :class="$style.forceHover" icon="panel-right" size="medium" />
<N8nIcon
:class="$style.pointerIcon"
icon="mouse-pointer"
color="text-dark"
size="large"
/>
</div>
<N8nIcon icon="ellipsis-vertical" size="small" color="text-base" />
<N8nRadioButtons
size="small"
:model-value="'expression'"
:disabled="true"
:options="[
{ label: locale.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: locale.baseText('parameterInput.expression'), value: 'expression' },
]"
/>
</div>
<div :class="$style.emptyText">
<N8nText color="text-base" size="medium" :bold="true">
{{ emptyTitle }}
</N8nText>
<N8nText color="text-base" size="small">
{{ emptySubtitle }}
</N8nText>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&.emptyContent {
text-align: center;
justify-content: center;
align-items: center;
.isNdvInFocusPanelEnabled & {
flex-direction: column-reverse;
}
.emptyText {
margin: 0 var(--spacing--xl);
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.focusParameterWrapper {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--2xs);
margin-block: var(--spacing--md);
.iconWrapper {
position: relative;
display: inline-block;
}
.pointerIcon {
position: absolute;
top: 100%;
left: 50%;
transform: translate(-20%, -30%);
pointer-events: none;
}
:global([class*='_disabled_']) {
cursor: default !important;
}
}
}
.tabHeader {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color--foreground);
padding: var(--spacing--2xs);
.tabHeaderText {
display: flex;
gap: var(--spacing--4xs);
align-items: baseline;
}
.buttonWrapper {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
}
.parameterDetailsWrapper {
display: flex;
height: 100%;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs);
.parameterOptionsWrapper {
display: flex;
justify-content: space-between;
}
.noExecutionDataTip {
align-content: center;
}
.editorContainer {
height: 0;
flex-grow: 1;
.editor {
display: flex;
height: 100%;
width: 100%;
align-items: normal;
font-size: var(--font-size--2xs);
:global(.cm-editor) {
background-color: var(--code--color--background);
width: 100%;
}
}
}
}
}
// We have this animation here to hide the short time between no longer
// executing the node and having runData available
.delayedShow {
opacity: 0;
transition: opacity 0.1s none;
animation: triggerShow 0.1s normal 0.1s forwards;
}
@keyframes triggerShow {
to {
opacity: 1;
}
}
.closeButton {
cursor: pointer;
}
.heightFull {
height: 100%;
}
.forceHover {
color: var(--button--color--text--secondary--hover-active-focus);
border-color: var(--button--border-color--secondary--hover-active-focus);
}
</style>
@@ -0,0 +1,145 @@
import { createCanvasGraphNode } from '@/features/workflows/canvas/__tests__/utils';
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { SET_NODE_TYPE } from '@/app/constants';
import { useFocusPanelStore } from '@/app/stores/focusPanel.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import { useVueFlow } from '@vue-flow/core';
import type { INodeProperties } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import { reactive } from 'vue';
import { useExperimentalNdvStore } from '@/features/workflows/canvas/experimental/experimentalNdv.store';
import { useSetupPanelStore } from '@/features/setupPanel/setupPanel.store';
import FocusSidebar from './FocusSidebar.vue';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
useRoute: () => reactive({}),
RouterLink: vi.fn(),
}));
describe('FocusSidebar', () => {
const renderComponent = createComponentRenderer(FocusSidebar, {
props: {
isCanvasReadOnly: false,
},
});
const parameter0: INodeProperties = {
displayName: 'P0',
name: 'p0',
type: 'string',
default: '',
description: '',
validateType: 'string',
};
const parameter1: INodeProperties = {
displayName: 'P1',
name: 'p1',
type: 'string',
default: '',
description: '',
validateType: 'string',
};
let experimentalNdvStore: ReturnType<typeof mockedStore<typeof useExperimentalNdvStore>>;
let focusPanelStore: ReturnType<typeof useFocusPanelStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let setupPanelStore: ReturnType<typeof mockedStore<typeof useSetupPanelStore>>;
beforeEach(() => {
const pinia = setActivePinia(createTestingPinia({ stubActions: false }));
localStorage.clear();
nodeTypesStore = useNodeTypesStore(pinia);
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: SET_NODE_TYPE,
properties: [parameter0, parameter1],
}),
]);
workflowsStore = useWorkflowsStore(pinia);
workflowsStore.setWorkflow(createTestWorkflow({ id: 'w0' }));
workflowsStore.setNodes([
createTestNode({ id: 'n0', name: 'N0', parameters: { p0: 'v0' }, type: SET_NODE_TYPE }),
]);
focusPanelStore = useFocusPanelStore(pinia);
focusPanelStore.toggleFocusPanel();
setupPanelStore = mockedStore(useSetupPanelStore);
setupPanelStore.isFeatureEnabled = false;
experimentalNdvStore = mockedStore(useExperimentalNdvStore);
experimentalNdvStore.isNdvInFocusPanelEnabled = true;
});
describe('when setup panel feature is enabled', () => {
beforeEach(() => {
setupPanelStore.isFeatureEnabled = true;
});
it('should render the setup panel tabs', async () => {
const rendered = renderComponent({});
expect(await rendered.findByTestId('setup-panel-tabs')).toBeInTheDocument();
});
it('should render the setup panel content by default', async () => {
const rendered = renderComponent({});
expect(await rendered.findByTestId('setup-panel-container')).toBeInTheDocument();
});
it('should switch to focus tab when a parameter is focused', async () => {
const rendered = renderComponent({});
// Initially shows setup panel
expect(await rendered.findByTestId('setup-panel-container')).toBeInTheDocument();
// Focus a parameter
focusPanelStore.openWithFocusedNodeParameter({
nodeId: 'n0',
parameter: parameter0,
parameterPath: 'parameters.p0',
});
// Should now show the focus parameter view
expect(await rendered.findByTestId('focus-parameter')).toBeInTheDocument();
expect(rendered.queryByTestId('setup-panel-container')).not.toBeInTheDocument();
});
it('should show parameter displayName as focus tab label when parameter is focused', async () => {
const rendered = renderComponent({});
focusPanelStore.openWithFocusedNodeParameter({
nodeId: 'n0',
parameter: parameter0,
parameterPath: 'parameters.p0',
});
await rendered.findByTestId('focus-parameter');
const tabs = rendered.getByTestId('setup-panel-tabs');
expect(tabs).toHaveTextContent('P0');
});
it('should show node name as focus tab label when node is selected but no parameter is focused', async () => {
const graphNode = createCanvasGraphNode({ id: 'n0' });
const vueFlow = useVueFlow('w0');
const rendered = renderComponent({});
vueFlow.addNodes([graphNode]);
vueFlow.addSelectedNodes([graphNode]);
await rendered.findByTestId('setup-panel-tabs');
const tabs = rendered.getByTestId('setup-panel-tabs');
expect(tabs).toHaveTextContent('N0');
});
});
});
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { useFocusPanelStore } from '@/app/stores/focusPanel.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useExperimentalNdvStore } from '@/features/workflows/canvas/experimental/experimentalNdv.store';
import { useSetupPanelStore } from '@/features/setupPanel/setupPanel.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useTelemetryContext } from '@/app/composables/useTelemetryContext';
import { computed, watch, useTemplateRef, onBeforeUnmount } from 'vue';
import { storeToRefs } from 'pinia';
import { useVueFlow } from '@vue-flow/core';
import { useActiveElement, useThrottleFn } from '@vueuse/core';
import { type CanvasNode, CanvasNodeRenderType } from '@/features/workflows/canvas/canvas.types';
import { type ContextMenuAction } from '@/features/shared/contextMenu/composables/useContextMenuItems';
import type { INodeUi, ResizeData } from '@/Interface';
import { N8nResizeWrapper } from '@n8n/design-system';
import FocusSidebarTabs from '@/features/setupPanel/components/FocusSidebarTabs.vue';
import SetupPanel from '@/features/setupPanel/components/SetupPanel.vue';
import FocusPanel from '@/app/components/FocusPanel.vue';
defineOptions({ name: 'FocusSidebar' });
defineProps<{
isCanvasReadOnly: boolean;
}>();
const emit = defineEmits<{
contextMenuAction: [action: ContextMenuAction, nodeIds: string[]];
}>();
const wrapperRef = useTemplateRef('wrapper');
const focusPanelStore = useFocusPanelStore();
const workflowsStore = useWorkflowsStore();
const experimentalNdvStore = useExperimentalNdvStore();
const setupPanelStore = useSetupPanelStore();
const telemetry = useTelemetry();
const deviceSupport = useDeviceSupport();
const vueFlow = useVueFlow(workflowsStore.workflowId);
const activeElement = useActiveElement();
useTelemetryContext({ view_shown: 'focus_panel' });
const { selectedTab } = storeToRefs(focusPanelStore);
const focusPanelActive = computed(() => focusPanelStore.focusPanelActive);
const focusPanelWidth = computed(() => focusPanelStore.focusPanelWidth);
const resolvedParameter = computed(() => focusPanelStore.resolvedParameter);
const isSetupPanelEnabled = computed(() => setupPanelStore.isFeatureEnabled);
const showSetupPanel = computed(
() => setupPanelStore.isFeatureEnabled && selectedTab.value === 'setup',
);
const node = computed<INodeUi | undefined>(() => {
if (!experimentalNdvStore.isNdvInFocusPanelEnabled || resolvedParameter.value) {
return resolvedParameter.value?.node;
}
const selected: CanvasNode | undefined = vueFlow.getSelectedNodes.value[0];
return selected?.data?.render.type === CanvasNodeRenderType.Default
? workflowsStore.allNodes.find((n) => n.id === selected.id)
: undefined;
});
const labelOverrides = computed(() => {
const focusLabel = resolvedParameter.value?.parameter.displayName ?? node.value?.name;
return focusLabel ? { focus: focusLabel } : undefined;
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === 's' && deviceSupport.isCtrlKeyPressed(event)) {
event.stopPropagation();
event.preventDefault();
}
}
const registerKeyboardListener = () => {
document.addEventListener('keydown', handleKeydown, true);
};
const unregisterKeyboardListener = () => {
document.removeEventListener('keydown', handleKeydown, true);
};
watch(
() => focusPanelStore.focusPanelActive,
(newValue) => {
if (newValue) {
registerKeyboardListener();
} else {
unregisterKeyboardListener();
}
},
{ immediate: true },
);
watch(activeElement, (active) => {
if (!node.value || !active || !wrapperRef.value?.contains(active)) {
return;
}
const path = active.closest('.parameter-input')?.getAttribute('data-parameter-path');
if (!path) {
return;
}
telemetry.track('User focused focus panel', {
node_id: node.value.id,
node_type: node.value.type,
parameter_path: path,
});
});
function onResize(event: ResizeData) {
focusPanelStore.updateWidth(event.width);
}
const onResizeThrottle = useThrottleFn(onResize, 10);
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
emit('contextMenuAction', action, nodeIds);
}
onBeforeUnmount(() => {
unregisterKeyboardListener();
});
</script>
<template>
<div
v-if="focusPanelActive"
ref="wrapper"
data-test-id="focus-sidebar"
:class="[
$style.wrapper,
'ignore-key-press-canvas',
{ [$style.isNdvInFocusPanelEnabled]: experimentalNdvStore.isNdvInFocusPanelEnabled },
]"
@keydown.stop
>
<N8nResizeWrapper
:width="focusPanelWidth"
:supported-directions="['left']"
:min-width="isSetupPanelEnabled ? 420 : 300"
:max-width="experimentalNdvStore.isNdvInFocusPanelEnabled ? undefined : 1000"
:grid-size="8"
:style="{ width: `${focusPanelWidth}px` }"
@resize="onResizeThrottle"
>
<div :class="$style.container">
<div v-if="isSetupPanelEnabled">
<FocusSidebarTabs v-model="selectedTab" :tab-labels="labelOverrides" />
</div>
<div v-if="showSetupPanel" :class="$style['setup-panel-wrapper']">
<SetupPanel />
</div>
<FocusPanel
v-else
:is-canvas-read-only="isCanvasReadOnly"
@context-menu-action="onContextMenuAction"
/>
</div>
</N8nResizeWrapper>
</div>
</template>
<style lang="scss" module>
.wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
border-left: 1px solid var(--color--foreground);
background: var(--color--background--light-3);
overflow: hidden;
height: 100%;
flex-grow: 0;
flex-shrink: 0;
}
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.setup-panel-wrapper {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
width: 100%;
}
</style>

Some files were not shown because too many files have changed in this diff Show More