Merge remote-tracking branch 'origin/master' into ADO-2728/feature-change-auto-add-of-chattrigger

This commit is contained in:
Charlie Kolb 2024-11-04 09:05:22 +01:00
commit 8e85ff55a0
No known key found for this signature in database
246 changed files with 2446 additions and 1381 deletions

View file

@ -11,3 +11,7 @@
# refactor: Run lintfix (no-changelog) (#7537) # refactor: Run lintfix (no-changelog) (#7537)
62c096710fab2f7e886518abdbded34b55e93f62 62c096710fab2f7e886518abdbded34b55e93f62
# refactor: Move test files alongside tested files (#11504)
7e58fc4fec468aca0b45d5bfe6150e1af632acbc

View file

@ -1,3 +1,45 @@
# [1.66.0](https://github.com/n8n-io/n8n/compare/n8n@1.65.0...n8n@1.66.0) (2024-10-31)
### Bug Fixes
* **Asana Node:** Fix issue with pagination ([#11415](https://github.com/n8n-io/n8n/issues/11415)) ([04c075a](https://github.com/n8n-io/n8n/commit/04c075a46bcc7b1964397f0244b0fde99476212d))
* **core:** Add 'user_id' to `license-community-plus-registered` telemetry event ([#11430](https://github.com/n8n-io/n8n/issues/11430)) ([7a8dafe](https://github.com/n8n-io/n8n/commit/7a8dafe9902fbc0d5001c50579c34959b95211ab))
* **core:** Add safeguard for command publishing ([#11337](https://github.com/n8n-io/n8n/issues/11337)) ([656439e](https://github.com/n8n-io/n8n/commit/656439e87138f9f96dea5a683cfdac3f661ffefb))
* **core:** Ensure `LoggerProxy` is not scoped ([#11379](https://github.com/n8n-io/n8n/issues/11379)) ([f4ea943](https://github.com/n8n-io/n8n/commit/f4ea943c9cb2321e41705de6c5c27535a0f5eae0))
* **core:** Ensure `remove-triggers-and-pollers` command is not debounced ([#11486](https://github.com/n8n-io/n8n/issues/11486)) ([529d4fc](https://github.com/n8n-io/n8n/commit/529d4fc3ef5206bd1b02d27634342cc50b45997e))
* **core:** Ensure job processor does not reprocess amended executions ([#11438](https://github.com/n8n-io/n8n/issues/11438)) ([c152a3a](https://github.com/n8n-io/n8n/commit/c152a3ac56f140a39eea4771a94f5a3082118df7))
* **core:** Fix Message Event Bus Metrics not counting up for labeled metrics ([#11396](https://github.com/n8n-io/n8n/issues/11396)) ([7fc3b25](https://github.com/n8n-io/n8n/commit/7fc3b25d21c6c4f1802f34b1ae065a65cac3001b))
* **core:** Fix resolving of $fromAI expression via `evaluateExpression` ([#11397](https://github.com/n8n-io/n8n/issues/11397)) ([2e64464](https://github.com/n8n-io/n8n/commit/2e6446454defbd3e5a47b66e6fd46d4f1b9fbd0f))
* **core:** Make execution and its data creation atomic ([#11392](https://github.com/n8n-io/n8n/issues/11392)) ([ed30d43](https://github.com/n8n-io/n8n/commit/ed30d43236bf3c6b657022636a02a41be01aa152))
* **core:** On unhandled rejections, extract the original exception correctly ([#11389](https://github.com/n8n-io/n8n/issues/11389)) ([8608bae](https://github.com/n8n-io/n8n/commit/8608baeb7ec302daddc8adca6e39778dcf7b2eda))
* **editor:** Fix TypeError: Cannot read properties of undefined (reading '0') ([#11399](https://github.com/n8n-io/n8n/issues/11399)) ([ae37c52](https://github.com/n8n-io/n8n/commit/ae37c520a91c75e353e818944b36a3619c0d8b4a))
* **editor:** Add Retry button for AI Assistant errors ([#11345](https://github.com/n8n-io/n8n/issues/11345)) ([7699240](https://github.com/n8n-io/n8n/commit/7699240073122cdef31cf109fd37fa66961f588a))
* **editor:** Change tooltip for workflow with execute workflow trigger ([#11374](https://github.com/n8n-io/n8n/issues/11374)) ([dcd6038](https://github.com/n8n-io/n8n/commit/dcd6038c3085135803cdaa546a239359a6d449eb))
* **editor:** Ensure toasts show above modal overlays ([#11410](https://github.com/n8n-io/n8n/issues/11410)) ([351134f](https://github.com/n8n-io/n8n/commit/351134f786af933f5f310bf8d9897269387635a0))
* **editor:** Fit view consistently after nodes are initialized on new canvas ([#11457](https://github.com/n8n-io/n8n/issues/11457)) ([497d637](https://github.com/n8n-io/n8n/commit/497d637fc5308b9c4a06bc764152fde1f1a9c130))
* **editor:** Fix adding connections when initializing workspace in templates view on new canvas ([#11451](https://github.com/n8n-io/n8n/issues/11451)) ([ea47b02](https://github.com/n8n-io/n8n/commit/ea47b025fb16c967d4fc73dcacc6e260d2aecd61))
* **editor:** Fix rendering of AI logs ([#11450](https://github.com/n8n-io/n8n/issues/11450)) ([73b0a80](https://github.com/n8n-io/n8n/commit/73b0a80ac92b4f4b5a300d0ec1c833b4395a222a))
* **editor:** Hide data mapping tooltip in credential edit modal ([#11356](https://github.com/n8n-io/n8n/issues/11356)) ([ff14dcb](https://github.com/n8n-io/n8n/commit/ff14dcb3a1ddaea4eca7c1ecd2e92c0abb0c413c))
* **editor:** Prevent running workflow that has issues if listening to webhook ([#11402](https://github.com/n8n-io/n8n/issues/11402)) ([8b0a48f](https://github.com/n8n-io/n8n/commit/8b0a48f53010378e497e4cc362fda75a958cf363))
* **editor:** Run external hooks after settings have been initialized ([#11423](https://github.com/n8n-io/n8n/issues/11423)) ([0ab24c8](https://github.com/n8n-io/n8n/commit/0ab24c814abd1787268750ba808993ab2735ac52))
* **editor:** Support middle click to scroll when using a mouse on new canvas ([#11384](https://github.com/n8n-io/n8n/issues/11384)) ([46f3b4a](https://github.com/n8n-io/n8n/commit/46f3b4a258f89f02e0d2bd1eef25a22e3a721167))
* **HTTP Request Tool Node:** Fix HTML response optimization issue ([#11439](https://github.com/n8n-io/n8n/issues/11439)) ([cf37e94](https://github.com/n8n-io/n8n/commit/cf37e94dd875e9f6ab1f189146fb34e7296af93c))
* **n8n Form Node:** Form Trigger does not wait in multi-form mode ([#11404](https://github.com/n8n-io/n8n/issues/11404)) ([151f4dd](https://github.com/n8n-io/n8n/commit/151f4dd7b8f800af424f8ae64cb8238975fb3cb8))
* Update required node js version in CONTRIBUTING.md ([#11437](https://github.com/n8n-io/n8n/issues/11437)) ([4f511aa](https://github.com/n8n-io/n8n/commit/4f511aab68651caa8fe47f70cd7cdb88bb06a3e2))
### Features
* **Anthropic Chat Model Node:** Add model claude-3-5-sonnet-20241022 ([#11465](https://github.com/n8n-io/n8n/issues/11465)) ([f6c8890](https://github.com/n8n-io/n8n/commit/f6c8890a8069de221b9b96e735418ecc9624cf7b))
* **core:** Handle nodes with multiple inputs and connections during partial executions ([#11376](https://github.com/n8n-io/n8n/issues/11376)) ([cb7c4d2](https://github.com/n8n-io/n8n/commit/cb7c4d29a6f042b590822e5b9c67fff0a8f0863d))
* **editor:** Add descriptive header to projects /workflow ([#11203](https://github.com/n8n-io/n8n/issues/11203)) ([5d19e8f](https://github.com/n8n-io/n8n/commit/5d19e8f2b45dc1abc5a8253f9e3a0fdacb1ebd91))
* **editor:** Improve placeholder for vector store tool ([#11483](https://github.com/n8n-io/n8n/issues/11483)) ([629e092](https://github.com/n8n-io/n8n/commit/629e09240785bc648ff6575f97910fbb4e77cdab))
* **editor:** Remove edge execution animation on new canvas ([#11446](https://github.com/n8n-io/n8n/issues/11446)) ([a701d87](https://github.com/n8n-io/n8n/commit/a701d87f5ba94ffc811e424b60e188b26ac6c1c5))
* **editor:** Update ownership pills ([#11155](https://github.com/n8n-io/n8n/issues/11155)) ([8147038](https://github.com/n8n-io/n8n/commit/8147038cf87dca657602e617e49698065bf1a63f))
# [1.65.0](https://github.com/n8n-io/n8n/compare/n8n@1.64.0...n8n@1.65.0) (2024-10-24) # [1.65.0](https://github.com/n8n-io/n8n/compare/n8n@1.64.0...n8n@1.65.0) (2024-10-24)

View file

@ -40,6 +40,14 @@ export function getOutputPanelDataContainer() {
return getOutputPanel().getByTestId('ndv-data-container'); return getOutputPanel().getByTestId('ndv-data-container');
} }
export function getOutputTableRows() {
return getOutputPanelDataContainer().find('table tr');
}
export function getOutputTableRow(row: number) {
return getOutputTableRows().eq(row);
}
export function getOutputPanelTable() { export function getOutputPanelTable() {
return getOutputPanelDataContainer().get('table'); return getOutputPanelDataContainer().get('table');
} }

View file

@ -69,6 +69,13 @@ export function getNodeCreatorPlusButton() {
return cy.getByTestId('node-creator-plus-button'); return cy.getByTestId('node-creator-plus-button');
} }
export function getCanvasNodes() {
return cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node'),
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
);
}
/** /**
* Actions * Actions
*/ */

View file

@ -90,6 +90,14 @@ function createRunDataWithError(inputMessage: string) {
routine: 'InitPostgres', routine: 'InitPostgres',
} as unknown as Error, } as unknown as Error,
} as ExecutionError, } as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}), }),
createMockNodeExecutionData(AGENT_NODE_NAME, { createMockNodeExecutionData(AGENT_NODE_NAME, {
executionStatus: 'error', executionStatus: 'error',
@ -124,14 +132,6 @@ function createRunDataWithError(inputMessage: string) {
description: 'Internal error', description: 'Internal error',
message: 'Internal error', message: 'Internal error',
} as unknown as ExecutionError, } as unknown as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}), }),
]; ];
} }

View file

@ -278,6 +278,9 @@ describe('Langchain Integration', () => {
}, },
}, },
}, },
metadata: {
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
},
inputOverride: { inputOverride: {
ai_languageModel: [ ai_languageModel: [
[ [
@ -316,9 +319,6 @@ describe('Langchain Integration', () => {
jsonData: { jsonData: {
main: { output: 'Hi there! How can I assist you today?' }, main: { output: 'Hi there! How can I assist you today?' },
}, },
metadata: {
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
},
}), }),
], ],
lastNodeExecuted: AGENT_NODE_NAME, lastNodeExecuted: AGENT_NODE_NAME,

View file

@ -1,21 +1,29 @@
import workflow from '../fixtures/Manual_wait_set.json'; import { getOutputTableRow } from '../composables/ndv';
import { getCanvasNodes, openNode } from '../composables/workflow';
import SIMPLE_WORKFLOW from '../fixtures/Manual_wait_set.json';
import WORKFLOW_WITH_PINNED from '../fixtures/Webhook_set_pinned.json';
import { importWorkflow, visitDemoPage } from '../pages/demo'; import { importWorkflow, visitDemoPage } from '../pages/demo';
import { errorToast } from '../pages/notifications'; import { errorToast } from '../pages/notifications';
import { WorkflowPage } from '../pages/workflow';
const workflowPage = new WorkflowPage();
describe('Demo', () => { describe('Demo', () => {
beforeEach(() => { beforeEach(() => {
cy.overrideSettings({ previewMode: true }); cy.overrideSettings({ previewMode: true });
cy.signout();
}); });
it('can import template', () => { it('can import template', () => {
visitDemoPage(); visitDemoPage();
errorToast().should('not.exist'); errorToast().should('not.exist');
importWorkflow(workflow); importWorkflow(SIMPLE_WORKFLOW);
workflowPage.getters.canvasNodes().should('have.length', 3); getCanvasNodes().should('have.length', 3);
});
it('can import workflow with pin data', () => {
visitDemoPage();
importWorkflow(WORKFLOW_WITH_PINNED);
getCanvasNodes().should('have.length', 2);
openNode('Webhook');
getOutputTableRow(0).should('include.text', 'headers');
getOutputTableRow(1).should('include.text', 'dragons');
}); });
it('can override theme to dark', () => { it('can override theme to dark', () => {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-monorepo", "name": "n8n-monorepo",
"version": "1.65.0", "version": "1.66.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=20.15", "node": ">=20.15",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/api-types", "name": "@n8n/api-types",
"version": "0.5.0", "version": "0.6.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/config", "name": "@n8n/config",
"version": "1.15.0", "version": "1.16.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -63,7 +63,7 @@ export class ToolVectorStore implements INodeType {
name: 'name', name: 'name',
type: 'string', type: 'string',
default: '', default: '',
placeholder: 'e.g. state_of_union_address', placeholder: 'e.g. company_knowledge_base',
validateType: 'string-alphanumeric', validateType: 'string-alphanumeric',
description: 'Name of the vector store', description: 'Name of the vector store',
}, },
@ -72,7 +72,7 @@ export class ToolVectorStore implements INodeType {
name: 'description', name: 'description',
type: 'string', type: 'string',
default: '', default: '',
placeholder: 'The most recent state of the Union address', placeholder: 'Retrieves data about [insert information about your data here]...',
typeOptions: { typeOptions: {
rows: 3, rows: 3,
}, },

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/n8n-nodes-langchain", "name": "@n8n/n8n-nodes-langchain",
"version": "1.65.0", "version": "1.66.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/permissions", "name": "@n8n/permissions",
"version": "0.15.0", "version": "0.16.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/task-runner", "name": "@n8n/task-runner",
"version": "1.3.0", "version": "1.4.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"start": "node dist/start.js", "start": "node dist/start.js",

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "1.65.0", "version": "1.66.0",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -65,6 +65,42 @@ describe('Publisher', () => {
JSON.stringify({ ...msg, senderId: hostId, selfSend: false, debounce: true }), JSON.stringify({ ...msg, senderId: hostId, selfSend: false, debounce: true }),
); );
}); });
it('should not debounce `add-webhooks-triggers-and-pollers`', async () => {
const publisher = new Publisher(logger, redisClientService, instanceSettings);
const msg = mock<PubSub.Command>({ command: 'add-webhooks-triggers-and-pollers' });
await publisher.publishCommand(msg);
expect(client.publish).toHaveBeenCalledWith(
'n8n.commands',
JSON.stringify({
...msg,
_isMockObject: true,
senderId: hostId,
selfSend: true,
debounce: false,
}),
);
});
it('should not debounce `remove-triggers-and-pollers`', async () => {
const publisher = new Publisher(logger, redisClientService, instanceSettings);
const msg = mock<PubSub.Command>({ command: 'remove-triggers-and-pollers' });
await publisher.publishCommand(msg);
expect(client.publish).toHaveBeenCalledWith(
'n8n.commands',
JSON.stringify({
...msg,
_isMockObject: true,
senderId: hostId,
selfSend: true,
debounce: false,
}),
);
});
}); });
describe('publishWorkerResponse', () => { describe('publishWorkerResponse', () => {

View file

@ -1,3 +1,5 @@
import type { PubSub } from './pubsub/pubsub.types';
export const QUEUE_NAME = 'jobs'; export const QUEUE_NAME = 'jobs';
export const JOB_TYPE_NAME = 'job'; export const JOB_TYPE_NAME = 'job';
@ -11,7 +13,7 @@ export const WORKER_RESPONSE_PUBSUB_CHANNEL = 'n8n.worker-response';
/** /**
* Commands that should be sent to the sender as well, e.g. during workflow activation and * Commands that should be sent to the sender as well, e.g. during workflow activation and
* deactivation in multi-main setup. */ * deactivation in multi-main setup. */
export const SELF_SEND_COMMANDS = new Set([ export const SELF_SEND_COMMANDS = new Set<PubSub.Command['command']>([
'add-webhooks-triggers-and-pollers', 'add-webhooks-triggers-and-pollers',
'remove-triggers-and-pollers', 'remove-triggers-and-pollers',
]); ]);
@ -20,7 +22,8 @@ export const SELF_SEND_COMMANDS = new Set([
* Commands that should not be debounced when received, e.g. during webhook handling in * Commands that should not be debounced when received, e.g. during webhook handling in
* multi-main setup. * multi-main setup.
*/ */
export const IMMEDIATE_COMMANDS = new Set([ export const IMMEDIATE_COMMANDS = new Set<PubSub.Command['command']>([
'add-webhooks-triggers-and-pollers', 'add-webhooks-triggers-and-pollers',
'remove-triggers-and-pollers',
'relay-execution-lifecycle-event', 'relay-execution-lifecycle-event',
]); ]);

View file

@ -0,0 +1,43 @@
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import config from '@/config';
import { UrlService } from '../url.service';
describe('UrlService', () => {
beforeEach(() => {
process.env.WEBHOOK_URL = undefined;
config.load(config.default);
});
describe('getInstanceBaseUrl', () => {
it('should set URL from N8N_EDITOR_BASE_URL', () => {
config.set('editorBaseUrl', 'https://example.com/');
process.env.WEBHOOK_URL = undefined;
const urlService = new UrlService(mock<GlobalConfig>());
expect(urlService.getInstanceBaseUrl()).toBe('https://example.com');
});
it('should set URL from WEBHOOK_URL', () => {
config.set('editorBaseUrl', '');
process.env.WEBHOOK_URL = 'https://example.com/';
const urlService = new UrlService(mock<GlobalConfig>());
expect(urlService.getInstanceBaseUrl()).toBe('https://example.com');
});
it('should trim quotes when setting URL from N8N_EDITOR_BASE_URL', () => {
config.set('editorBaseUrl', '"https://example.com"');
process.env.WEBHOOK_URL = undefined;
const urlService = new UrlService(mock<GlobalConfig>());
expect(urlService.getInstanceBaseUrl()).toBe('https://example.com');
});
it('should trim quotes when setting URL from WEBHOOK_URL', () => {
config.set('editorBaseUrl', '');
process.env.WEBHOOK_URL = '"https://example.com/"';
const urlService = new UrlService(mock<GlobalConfig>());
expect(urlService.getInstanceBaseUrl()).toBe('https://example.com');
});
});
});

View file

@ -14,7 +14,7 @@ export class UrlService {
/** Returns the base URL of the webhooks */ /** Returns the base URL of the webhooks */
getWebhookBaseUrl() { getWebhookBaseUrl() {
let urlBaseWebhook = process.env.WEBHOOK_URL ?? this.baseUrl; let urlBaseWebhook = this.trimQuotes(process.env.WEBHOOK_URL) || this.baseUrl;
if (!urlBaseWebhook.endsWith('/')) { if (!urlBaseWebhook.endsWith('/')) {
urlBaseWebhook += '/'; urlBaseWebhook += '/';
} }
@ -23,7 +23,7 @@ export class UrlService {
/** Return the n8n instance base URL without trailing slash */ /** Return the n8n instance base URL without trailing slash */
getInstanceBaseUrl(): string { getInstanceBaseUrl(): string {
const n8nBaseUrl = config.getEnv('editorBaseUrl') || this.getWebhookBaseUrl(); const n8nBaseUrl = this.trimQuotes(config.getEnv('editorBaseUrl')) || this.getWebhookBaseUrl();
return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl; return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl;
} }
@ -36,4 +36,9 @@ export class UrlService {
} }
return `${protocol}://${host}:${port}${path}`; return `${protocol}://${host}:${port}${path}`;
} }
/** Remove leading and trailing double quotes from a URL. */
private trimQuotes(url?: string) {
return url?.replace(/^["]+|["]+$/g, '') ?? '';
}
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "1.65.0", "version": "1.66.0",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-design-system", "name": "n8n-design-system",
"version": "1.55.0", "version": "1.56.0",
"main": "src/main.ts", "main": "src/main.ts",
"import": "src/main.ts", "import": "src/main.ts",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "1.65.0", "version": "1.66.0",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View file

@ -52,7 +52,6 @@ import type {
AI_NODE_CREATOR_VIEW, AI_NODE_CREATOR_VIEW,
CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY,
SignInType, SignInType,
FAKE_DOOR_FEATURES,
TRIGGER_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW,
REGULAR_NODE_CREATOR_VIEW, REGULAR_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW, AI_OTHERS_NODE_CREATOR_VIEW,
@ -62,7 +61,6 @@ import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
import type { ProjectSharingData } from '@/types/projects.types'; import type { ProjectSharingData } from '@/types/projects.types';
import type { BaseTextKey } from './plugins/i18n';
export * from 'n8n-design-system/types'; export * from 'n8n-design-system/types';
@ -1036,24 +1034,6 @@ export interface NotificationOptions extends Partial<ElementNotificationOptions>
message: string | ElementNotificationOptions['message']; message: string | ElementNotificationOptions['message'];
} }
export type IFakeDoor = {
id: FAKE_DOOR_FEATURES;
featureName: BaseTextKey;
icon?: string;
infoText?: BaseTextKey;
actionBoxTitle: BaseTextKey;
actionBoxDescription: BaseTextKey;
actionBoxButtonLabel?: BaseTextKey;
linkURL: string;
uiLocations: IFakeDoorLocation[];
};
export type IFakeDoorLocation =
| 'settings'
| 'settings/users'
| 'credentialsModal'
| 'workflowShareModal';
export type NodeFilterType = export type NodeFilterType =
| typeof REGULAR_NODE_CREATOR_VIEW | typeof REGULAR_NODE_CREATOR_VIEW
| typeof TRIGGER_NODE_CREATOR_VIEW | typeof TRIGGER_NODE_CREATOR_VIEW

View file

@ -1,7 +1,7 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import Assignment from '../Assignment.vue'; import Assignment from './Assignment.vue';
import { defaultSettings } from '@/__tests__/defaults'; import { defaultSettings } from '@/__tests__/defaults';
import { STORES } from '@/constants'; import { STORES } from '@/constants';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';

View file

@ -4,7 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { fireEvent, within } from '@testing-library/vue'; import { fireEvent, within } from '@testing-library/vue';
import * as workflowHelpers from '@/composables/useWorkflowHelpers'; import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import AssignmentCollection from '../AssignmentCollection.vue'; import AssignmentCollection from './AssignmentCollection.vue';
import { STORES } from '@/constants'; import { STORES } from '@/constants';
import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
@ -117,7 +117,7 @@ describe('AssignmentCollection.vue', () => {
await dropAssignment({ key: 'objectKey', value: {}, dropArea }); await dropAssignment({ key: 'objectKey', value: {}, dropArea });
await dropAssignment({ key: 'arrayKey', value: [], dropArea }); await dropAssignment({ key: 'arrayKey', value: [], dropArea });
let assignments = await findAllByTestId('assignment'); const assignments = await findAllByTestId('assignment');
expect(assignments.length).toBe(5); expect(assignments.length).toBe(5);
expect(getAssignmentType(assignments[0])).toEqual('Boolean'); expect(getAssignmentType(assignments[0])).toEqual('Boolean');

View file

@ -98,10 +98,10 @@ function getIssues(index: number): string[] {
return issues.value[`${props.parameter.name}.${index}`] ?? []; return issues.value[`${props.parameter.name}.${index}`] ?? [];
} }
function optionSelected(action: 'clearAll' | 'addAll') { function optionSelected(action: string) {
if (action === 'clearAll') { if (action === 'clearAll') {
state.paramValue.assignments = []; state.paramValue.assignments = [];
} else { } else if (action === 'addAll' && inputData.value) {
const newAssignments = inputDataToAssignments(inputData.value); const newAssignments = inputDataToAssignments(inputData.value);
state.paramValue.assignments = state.paramValue.assignments.concat(newAssignments); state.paramValue.assignments = state.paramValue.assignments.concat(newAssignments);
} }

View file

@ -1,7 +1,7 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import TypeSelect from '../TypeSelect.vue'; import TypeSelect from './TypeSelect.vue';
const DEFAULT_SETUP = { const DEFAULT_SETUP = {
pinia: createTestingPinia(), pinia: createTestingPinia(),

View file

@ -1,6 +1,6 @@
import { CompletionContext } from '@codemirror/autocomplete'; import { CompletionContext } from '@codemirror/autocomplete';
import { EditorSelection, EditorState } from '@codemirror/state'; import { EditorSelection, EditorState } from '@codemirror/state';
import { useItemFieldCompletions } from '../itemField.completions'; import { useItemFieldCompletions } from './itemField.completions';
describe('inputMethodCompletions', () => { describe('inputMethodCompletions', () => {
test('should return completions for $input.item.|', () => { test('should return completions for $input.item.|', () => {

View file

@ -1,6 +1,6 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import CollectionParameter from '../CollectionParameter.vue'; import CollectionParameter from './CollectionParameter.vue';
const renderComponent = createComponentRenderer(CollectionParameter, { const renderComponent = createComponentRenderer(CollectionParameter, {
pinia: createTestingPinia(), pinia: createTestingPinia(),

View file

@ -1,5 +1,5 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import CommunityPackageInstallModal from '../CommunityPackageInstallModal.vue'; import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, STORES } from '@/constants'; import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, STORES } from '@/constants';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';

View file

@ -22,7 +22,6 @@ import { NodeHelpers } from 'n8n-workflow';
import CredentialConfig from '@/components/CredentialEdit/CredentialConfig.vue'; import CredentialConfig from '@/components/CredentialEdit/CredentialConfig.vue';
import CredentialInfo from '@/components/CredentialEdit/CredentialInfo.vue'; import CredentialInfo from '@/components/CredentialEdit/CredentialInfo.vue';
import CredentialSharing from '@/components/CredentialEdit/CredentialSharing.ee.vue'; import CredentialSharing from '@/components/CredentialEdit/CredentialSharing.ee.vue';
import FeatureComingSoon from '@/components/FeatureComingSoon.vue';
import InlineNameEdit from '@/components/InlineNameEdit.vue'; import InlineNameEdit from '@/components/InlineNameEdit.vue';
import Modal from '@/components/Modal.vue'; import Modal from '@/components/Modal.vue';
import SaveButton from '@/components/SaveButton.vue'; import SaveButton from '@/components/SaveButton.vue';
@ -518,14 +517,13 @@ async function loadCurrentCredential() {
function onTabSelect(tab: string) { function onTabSelect(tab: string) {
activeTab.value = tab; activeTab.value = tab;
const tabName: string = tab.replaceAll('coming-soon/', '');
const credType: string = credentialType.value ? credentialType.value.name : ''; const credType: string = credentialType.value ? credentialType.value.name : '';
const activeNode: INode | null = ndvStore.activeNode; const activeNode: INode | null = ndvStore.activeNode;
telemetry.track('User viewed credential tab', { telemetry.track('User viewed credential tab', {
credential_type: credType, credential_type: credType,
node_type: activeNode ? activeNode.type : null, node_type: activeNode ? activeNode.type : null,
tab: tabName, tab,
workflow_id: workflowsStore.workflowId, workflow_id: workflowsStore.workflowId,
credential_id: credentialId.value, credential_id: credentialId.value,
sharing_enabled: EnterpriseEditionFeature.Sharing, sharing_enabled: EnterpriseEditionFeature.Sharing,
@ -1130,9 +1128,6 @@ function resetCredentialData(): void {
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent"> <div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
<CredentialInfo :current-credential="currentCredential" /> <CredentialInfo :current-credential="currentCredential" />
</div> </div>
<div v-else-if="activeTab.startsWith('coming-soon')" :class="$style.mainContent">
<FeatureComingSoon :feature-id="activeTab.split('/')[1]"></FeatureComingSoon>
</div>
</div> </div>
</template> </template>
</Modal> </Modal>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ProjectSharing from '@/components/Projects/ProjectSharing.vue'; import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { EnterpriseEditionFeature } from '@/constants'; import { EnterpriseEditionFeature } from '@/constants';
import type { ICredentialsDecryptedResponse, ICredentialsResponse } from '@/Interface'; import type { ICredentialsDecryptedResponse, ICredentialsResponse } from '@/Interface';
import type { PermissionsRecord } from '@/permissions'; import type { PermissionsRecord } from '@/permissions';
@ -39,6 +40,8 @@ const settingsStore = useSettingsStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const rolesStore = useRolesStore(); const rolesStore = useRolesStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const sharedWithProjects = ref([...(props.credential?.sharedWithProjects ?? [])]); const sharedWithProjects = ref([...(props.credential?.sharedWithProjects ?? [])]);
const isSharingEnabled = computed( const isSharingEnabled = computed(
@ -107,7 +110,7 @@ onMounted(async () => {
}); });
function goToUpgrade() { function goToUpgrade() {
void uiStore.goToUpgrade('credential_sharing', 'upgrade-credentials-sharing'); void pageRedirectionHelper.goToUpgrade('credential_sharing', 'upgrade-credentials-sharing');
} }
</script> </script>

View file

@ -7,7 +7,7 @@ import CredentialIcon from '@/components/CredentialIcon.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
import { useNodeTypesStore } from '../../stores/nodeTypes.store'; import { useNodeTypesStore } from '../stores/nodeTypes.store';
describe('CredentialIcon', () => { describe('CredentialIcon', () => {
const renderComponent = createComponentRenderer(CredentialIcon, { const renderComponent = createComponentRenderer(CredentialIcon, {

View file

@ -4,7 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue'; import { fireEvent } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import DropArea from '../DropArea.vue'; import DropArea from './DropArea.vue';
const renderComponent = createComponentRenderer(DropArea, { const renderComponent = createComponentRenderer(DropArea, {
pinia: createTestingPinia(), pinia: createTestingPinia(),

View file

@ -1,82 +0,0 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import type { IFakeDoor } from '@/Interface';
import { useRootStore } from '@/stores/root.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
export default defineComponent({
name: 'FeatureComingSoon',
props: {
featureId: {
type: String,
required: true,
},
showTitle: {
type: Boolean,
default: false,
},
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUIStore, useUsersStore),
userId(): string {
return this.usersStore.currentUserId || '';
},
instanceId(): string {
return this.rootStore.instanceId;
},
featureInfo(): IFakeDoor | undefined {
return this.uiStore.fakeDoorsById[this.featureId];
},
},
methods: {
openLinkPage() {
if (this.featureInfo) {
window.open(
`${this.featureInfo.linkURL}&u=${this.instanceId}#${this.userId}&v=${this.rootStore.versionCli}`,
'_blank',
);
this.$telemetry.track('user clicked feature waiting list button', {
feature: this.featureId,
});
}
},
},
});
</script>
<template>
<div v-if="featureInfo" :class="[$style.container]">
<div v-if="showTitle" class="mb-2xl">
<n8n-heading size="2xlarge">
{{ $locale.baseText(featureInfo.featureName) }}
</n8n-heading>
</div>
<div v-if="featureInfo.infoText" class="mb-l">
<n8n-info-tip theme="info" type="note">
<span v-n8n-html="$locale.baseText(featureInfo.infoText)"></span>
</n8n-info-tip>
</div>
<div :class="$style.actionBoxContainer">
<n8n-action-box
:description="$locale.baseText(featureInfo.actionBoxDescription)"
:button-text="
$locale.baseText(featureInfo.actionBoxButtonLabel || 'fakeDoor.actionBox.button.label')
"
@click:button="openLinkPage"
>
<template #heading>
<span v-n8n-html="$locale.baseText(featureInfo.actionBoxTitle)" />
</template>
</n8n-action-box>
</div>
</div>
</template>
<style lang="scss" module>
.actionBoxContainer {
text-align: center;
}
</style>

View file

@ -6,7 +6,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { within, waitFor } from '@testing-library/vue'; import { within, waitFor } from '@testing-library/vue';
import { getFilterOperator } from '../utils'; import { getFilterOperator } from './utils';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
const DEFAULT_SETUP = { const DEFAULT_SETUP = {

View file

@ -1,4 +1,4 @@
import { getFilterOperator, handleOperatorChange, inferOperatorType } from '../utils'; import { getFilterOperator, handleOperatorChange, inferOperatorType } from './utils';
describe('FilterConditions > utils', () => { describe('FilterConditions > utils', () => {
describe('handleOperatorChange', () => { describe('handleOperatorChange', () => {

View file

@ -7,7 +7,7 @@ import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
import { userEvent } from '@testing-library/user-event'; import { userEvent } from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue'; import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { htmlEditorEventBus } from '../../event-bus'; import { htmlEditorEventBus } from '../event-bus';
const DEFAULT_SETUP = { const DEFAULT_SETUP = {
props: { props: {

View file

@ -1,6 +1,6 @@
import { renderComponent } from '@/__tests__/render'; import { renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import InlineExpressionEditorOutput from '../InlineExpressionEditorOutput.vue'; import InlineExpressionEditorOutput from './InlineExpressionEditorOutput.vue';
describe('InlineExpressionEditorOutput.vue', () => { describe('InlineExpressionEditorOutput.vue', () => {
test('should render duplicate segments correctly', async () => { test('should render duplicate segments correctly', async () => {

View file

@ -1,5 +1,5 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import InputTriple from '../InputTriple.vue'; import InputTriple from './InputTriple.vue';
const renderComponent = createComponentRenderer(InputTriple); const renderComponent = createComponentRenderer(InputTriple);

View file

@ -15,6 +15,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { createFormEventBus, createEventBus } from 'n8n-design-system/utils'; import { createFormEventBus, createEventBus } from 'n8n-design-system/utils';
import { useClipboard } from '@/composables/useClipboard'; import { useClipboard } from '@/composables/useClipboard';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/; const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
@ -43,6 +44,7 @@ export default defineComponent({
return { return {
clipboard, clipboard,
...useToast(), ...useToast(),
...usePageRedirectionHelper(),
}; };
}, },
data() { data() {
@ -277,7 +279,7 @@ export default defineComponent({
} }
}, },
goToUpgradeAdvancedPermissions() { goToUpgradeAdvancedPermissions() {
void this.uiStore.goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions'); void this.goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions');
}, },
}, },
}); });

View file

@ -56,6 +56,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import type { BaseTextKey } from '@/plugins/i18n'; import type { BaseTextKey } from '@/plugins/i18n';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { useNodeViewVersionSwitcher } from '@/composables/useNodeViewVersionSwitcher'; import { useNodeViewVersionSwitcher } from '@/composables/useNodeViewVersionSwitcher';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = defineProps<{ const props = defineProps<{
readOnly?: boolean; readOnly?: boolean;
@ -89,6 +90,7 @@ const message = useMessage();
const toast = useToast(); const toast = useToast();
const documentTitle = useDocumentTitle(); const documentTitle = useDocumentTitle();
const workflowHelpers = useWorkflowHelpers({ router }); const workflowHelpers = useWorkflowHelpers({ router });
const pageRedirectionHelper = usePageRedirectionHelper();
const isTagsEditEnabled = ref(false); const isTagsEditEnabled = ref(false);
const isNameEditEnabled = ref(false); const isNameEditEnabled = ref(false);
@ -584,11 +586,11 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
} }
function goToUpgrade() { function goToUpgrade() {
void uiStore.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing'); void pageRedirectionHelper.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing');
} }
function goToWorkflowHistoryUpgrade() { function goToWorkflowHistoryUpgrade() {
void uiStore.goToUpgrade('workflow-history', 'upgrade-workflow-history'); void pageRedirectionHelper.goToUpgrade('workflow-history', 'upgrade-workflow-history');
} }
function showCreateWorkflowSuccessToast(id?: string) { function showCreateWorkflowSuccessToast(id?: string) {

View file

@ -21,6 +21,7 @@ import { useUserHelpers } from '@/composables/useUserHelpers';
import { ABOUT_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS } from '@/constants'; import { ABOUT_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS } from '@/constants';
import { useBugReporting } from '@/composables/useBugReporting'; import { useBugReporting } from '@/composables/useBugReporting';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore(); const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
const cloudPlanStore = useCloudPlanStore(); const cloudPlanStore = useCloudPlanStore();
@ -38,6 +39,7 @@ const locale = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const pageRedirectionHelper = usePageRedirectionHelper();
const { getReportingURL } = useBugReporting(); const { getReportingURL } = useBugReporting();
useUserHelpers(router, route); useUserHelpers(router, route);
@ -260,7 +262,7 @@ const handleSelect = (key: string) => {
break; break;
} }
case 'cloud-admin': { case 'cloud-admin': {
void cloudPlanStore.redirectToDashboard(); void pageRedirectionHelper.goToDashboard();
break; break;
} }
case 'quickstart': case 'quickstart':

View file

@ -271,7 +271,7 @@ const nodeClass = computed(() => {
const nodeExecutionStatus = computed(() => { const nodeExecutionStatus = computed(() => {
const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[props.name]; const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[props.name];
if (nodeExecutionRunData) { if (nodeExecutionRunData) {
return nodeExecutionRunData.filter(Boolean)[0]?.executionStatus ?? ''; return nodeExecutionRunData.filter(Boolean)?.[0]?.executionStatus ?? '';
} }
return ''; return '';
}); });

View file

@ -1,5 +1,5 @@
import { screen } from '@testing-library/vue'; import { screen } from '@testing-library/vue';
import CategoryItem from '../ItemTypes/CategoryItem.vue'; import CategoryItem from './ItemTypes/CategoryItem.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CategoryItem); const renderComponent = createComponentRenderer(CategoryItem);

View file

@ -8,7 +8,7 @@ import {
mockActionCreateElement, mockActionCreateElement,
mockViewCreateElement, mockViewCreateElement,
mockSectionCreateElement, mockSectionCreateElement,
} from './utils'; } from './__tests__/utils';
import ItemsRenderer from '@/components/Node/NodeCreator/Renderers/ItemsRenderer.vue'; import ItemsRenderer from '@/components/Node/NodeCreator/Renderers/ItemsRenderer.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';

View file

@ -29,6 +29,7 @@ import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
const emit = defineEmits<{ const emit = defineEmits<{
nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]]; nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]];
@ -47,6 +48,8 @@ const {
actionsCategoryLocales, actionsCategoryLocales,
} = useActions(); } = useActions();
const nodeCreatorStore = useNodeCreatorStore();
// We only inject labels if search is empty // We only inject labels if search is empty
const parsedTriggerActions = computed(() => const parsedTriggerActions = computed(() =>
parseActions(actions.value, actionsCategoryLocales.value.triggers, false), parseActions(actions.value, actionsCategoryLocales.value.triggers, false),
@ -182,7 +185,7 @@ function trackActionsView() {
}; };
void useExternalHooks().run('nodeCreateList.onViewActions', trackingPayload); void useExternalHooks().run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload); nodeCreatorStore.onViewActions(trackingPayload);
} }
function resetSearch() { function resetSearch() {
@ -206,7 +209,7 @@ function addHttpNode() {
void useExternalHooks().run('nodeCreateList.onActionsCustmAPIClicked', { void useExternalHooks().run('nodeCreateList.onActionsCustmAPIClicked', {
app_identifier, app_identifier,
}); });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier }); nodeCreatorStore.onActionsCustomAPIClicked({ app_identifier });
} }
// Anonymous component to handle triggers and actions rendering order // Anonymous component to handle triggers and actions rendering order

View file

@ -23,7 +23,6 @@ import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'; import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
import NoResults from '../Panel/NoResults.vue'; import NoResults from '../Panel/NoResults.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils'; import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
@ -36,11 +35,10 @@ const emit = defineEmits<{
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const telemetry = useTelemetry();
const uiStore = useUIStore(); const uiStore = useUIStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
const { mergedNodes, actions } = useNodeCreatorStore(); const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
const { pushViewStack, popViewStack } = useViewStacks(); const { pushViewStack, popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation(); const { registerKeyHook } = useKeyboardNavigation();
@ -83,7 +81,7 @@ function onSelected(item: INodeCreateElement) {
sections: item.properties.sections, sections: item.properties.sections,
}); });
telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { onSubcategorySelected({
subcategory: item.key, subcategory: item.key,
}); });
} }
@ -153,9 +151,6 @@ function onSelected(item: INodeCreateElement) {
if (item.type === 'link') { if (item.type === 'link') {
window.open(item.properties.url, '_blank'); window.open(item.properties.url, '_blank');
telemetry.trackNodesPanel('nodeCreateList.onLinkSelected', {
link: item.properties.url,
});
} }
} }

View file

@ -4,8 +4,8 @@ import { createPinia } from 'pinia';
import { screen, fireEvent } from '@testing-library/vue'; import { screen, fireEvent } from '@testing-library/vue';
import type { INodeTypeDescription } from 'n8n-workflow'; import type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { mockSimplifiedNodeType } from './utils'; import { mockSimplifiedNodeType } from './__tests__/utils';
import NodesListPanel from '../Panel/NodesListPanel.vue'; import NodesListPanel from './Panel/NodesListPanel.vue';
import { REGULAR_NODE_CREATOR_VIEW } from '@/constants'; import { REGULAR_NODE_CREATOR_VIEW } from '@/constants';
import type { NodeFilterType } from '@/Interface'; import type { NodeFilterType } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';

View file

@ -17,12 +17,15 @@ import SearchBar from './SearchBar.vue';
import ActionsRenderer from '../Modes/ActionsMode.vue'; import ActionsRenderer from '../Modes/ActionsMode.vue';
import NodesRenderer from '../Modes/NodesMode.vue'; import NodesRenderer from '../Modes/NodesMode.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce';
const i18n = useI18n(); const i18n = useI18n();
const { callDebounced } = useDebounce();
const { mergedNodes } = useNodeCreatorStore(); const { mergedNodes } = useNodeCreatorStore();
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks(); const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation(); const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
const nodeCreatorStore = useNodeCreatorStore();
const activeViewStack = computed(() => useViewStacks().activeViewStack); const activeViewStack = computed(() => useViewStacks().activeViewStack);
@ -55,6 +58,19 @@ function onSearch(value: string) {
if (activeViewStack.value.uuid) { if (activeViewStack.value.uuid) {
updateCurrentViewStack({ search: value }); updateCurrentViewStack({ search: value });
void setActiveItemIndex(getDefaultActiveIndex(value)); void setActiveItemIndex(getDefaultActiveIndex(value));
if (value.length) {
callDebounced(
nodeCreatorStore.onNodeFilterChanged,
{ trailing: true, debounceTime: 2000 },
{
newValue: value,
filteredNodes: activeViewStack.value.items ?? [],
filterMode: activeViewStack.value.rootView ?? 'Regular',
subcategory: activeViewStack.value.subcategory,
title: activeViewStack.value.title,
},
);
}
} }
} }
@ -299,6 +315,7 @@ function onBackButton() {
margin-top: var(--spacing-4xs); margin-top: var(--spacing-4xs);
font-size: var(--font-size-s); font-size: var(--font-size-s);
line-height: 19px; line-height: 19px;
color: var(--color-text-base); color: var(--color-text-base);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
} }

View file

@ -8,7 +8,7 @@ import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import { useViewStacks } from '../composables/useViewStacks'; import { useViewStacks } from '../composables/useViewStacks';
import ItemsRenderer from './ItemsRenderer.vue'; import ItemsRenderer from './ItemsRenderer.vue';
import CategoryItem from '../ItemTypes/CategoryItem.vue'; import CategoryItem from '../ItemTypes/CategoryItem.vue';
import { useTelemetry } from '@/composables/useTelemetry'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
export interface Props { export interface Props {
elements: INodeCreateElement[]; elements: INodeCreateElement[];
@ -24,10 +24,10 @@ const props = withDefaults(defineProps<Props>(), {
elements: () => [], elements: () => [],
}); });
const telemetry = useTelemetry();
const { popViewStack } = useViewStacks(); const { popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation(); const { registerKeyHook } = useKeyboardNavigation();
const { workflowId } = useWorkflowsStore(); const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId); const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length); const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length);
@ -38,10 +38,11 @@ function toggleExpanded() {
} }
function setExpanded(isExpanded: boolean) { function setExpanded(isExpanded: boolean) {
const prev = expanded.value;
expanded.value = isExpanded; expanded.value = isExpanded;
if (expanded.value) { if (expanded.value && !prev) {
telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { nodeCreatorStore.onCategoryExpanded({
category_name: props.category, category_name: props.category,
workflow_id: workflowId, workflow_id: workflowId,
}); });

View file

@ -339,7 +339,11 @@ export const useActions = () => {
return storeWatcher; return storeWatcher;
} }
function trackActionSelected(action: IUpdateInformation, telemetry: Telemetry, rootView: string) { function trackActionSelected(
action: IUpdateInformation,
_telemetry: Telemetry,
rootView: string,
) {
const payload = { const payload = {
node_type: action.key, node_type: action.key,
action: action.name, action: action.name,
@ -347,7 +351,7 @@ export const useActions = () => {
resource: (action.value as INodeParameters).resource || '', resource: (action.value as INodeParameters).resource || '',
}; };
void useExternalHooks().run('nodeCreateList.addAction', payload); void useExternalHooks().run('nodeCreateList.addAction', payload);
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload); useNodeCreatorStore().onAddActions(payload);
} }
return { return {

View file

@ -3,7 +3,7 @@ import { createTestingPinia } from '@pinia/testing';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useActions } from '../composables/useActions'; import { useActions } from './composables/useActions';
import { import {
AGENT_NODE_TYPE, AGENT_NODE_TYPE,
GITHUB_TRIGGER_NODE_TYPE, GITHUB_TRIGGER_NODE_TYPE,

View file

@ -1,5 +1,5 @@
import { NodeConnectionType, type INodeProperties, type INodeTypeDescription } from 'n8n-workflow'; import { NodeConnectionType, type INodeProperties, type INodeTypeDescription } from 'n8n-workflow';
import { useActionsGenerator } from '../composables/useActionsGeneration'; import { useActionsGenerator } from './composables/useActionsGeneration';
describe('useActionsGenerator', () => { describe('useActionsGenerator', () => {
const { generateMergedNodesAndActions } = useActionsGenerator(); const { generateMergedNodesAndActions } = useActionsGenerator();

View file

@ -1,6 +1,6 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { defineComponent, computed } from 'vue'; import { defineComponent, computed } from 'vue';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation'; import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';

View file

@ -1,6 +1,10 @@
import type { SectionCreateElement } from '@/Interface'; import type { SectionCreateElement } from '@/Interface';
import { formatTriggerActionName, groupItemsInSections, sortNodeCreateElements } from '../utils'; import { formatTriggerActionName, groupItemsInSections, sortNodeCreateElements } from './utils';
import { mockActionCreateElement, mockNodeCreateElement, mockSectionCreateElement } from './utils'; import {
mockActionCreateElement,
mockNodeCreateElement,
mockSectionCreateElement,
} from './__tests__/utils';
describe('NodeCreator - utils', () => { describe('NodeCreator - utils', () => {
describe('groupItemsInSections', () => { describe('groupItemsInSections', () => {

View file

@ -124,7 +124,7 @@ const currentWorkflow = computed(() =>
); );
const hasForeignCredential = computed(() => props.foreignCredentials.length > 0); const hasForeignCredential = computed(() => props.foreignCredentials.length > 0);
const isHomeProjectTeam = computed( const isHomeProjectTeam = computed(
() => currentWorkflow.value.homeProject?.type === ProjectTypes.Team, () => currentWorkflow.value?.homeProject?.type === ProjectTypes.Team,
); );
const isReadOnly = computed( const isReadOnly = computed(
() => props.readOnly || (hasForeignCredential.value && !isHomeProjectTeam.value), () => props.readOnly || (hasForeignCredential.value && !isHomeProjectTeam.value),

View file

@ -100,14 +100,15 @@ const isTriggerNode = computed(() => {
}); });
const hasAiMetadata = computed(() => { const hasAiMetadata = computed(() => {
if (isNodeRunning.value || !workflowRunData.value) {
return false;
}
if (node.value) { if (node.value) {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(node.value.name); const connectedSubNodes = props.workflow.getParentNodes(node.value.name, 'ALL_NON_MAIN');
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
if (!resultData || !Array.isArray(resultData) || resultData.length === 0) { return resultData && Array.isArray(resultData) && resultData.length > 0;
return false;
}
return !!resultData[resultData.length - 1].metadata;
} }
return false; return false;
}); });
@ -295,6 +296,7 @@ const activatePane = () => {
:block-u-i="blockUI" :block-u-i="blockUI"
:is-production-execution-preview="isProductionExecutionPreview" :is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isPaneActive" :is-pane-active="isPaneActive"
:hide-pagination="outputMode === 'logs'"
pane-type="output" pane-type="output"
:data-output-type="outputMode" :data-output-type="outputMode"
@activate-pane="activatePane" @activate-pane="activatePane"
@ -368,7 +370,7 @@ const activatePane = () => {
</template> </template>
<template v-if="outputMode === 'logs' && node" #content> <template v-if="outputMode === 'logs' && node" #content>
<RunDataAi :node="node" :run-index="runIndex" /> <RunDataAi :node="node" :run-index="runIndex" :workflow="workflow" />
</template> </template>
<template #recovered-artificial-output-data> <template #recovered-artificial-output-data>

View file

@ -6,7 +6,7 @@ import { createTestingPinia } from '@pinia/testing';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { waitFor } from '@testing-library/vue'; import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import type { useNodeTypesStore } from '../../stores/nodeTypes.store'; import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>; let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;

View file

@ -0,0 +1,123 @@
import { renderComponent } from '@/__tests__/render';
import userEvent from '@testing-library/user-event';
import { within } from '@testing-library/vue';
import { waitFor } from '@testing-library/vue';
import ParameterOptions from './ParameterOptions.vue';
const DEFAULT_PARAMETER = {
displayName: 'Fields to Set',
name: 'assignments',
type: 'assignmentCollection',
default: {},
};
describe('ParameterOptions', () => {
it('renders default options properly', () => {
const { getByTestId } = renderComponent(ParameterOptions, {
props: {
parameter: DEFAULT_PARAMETER,
isReadOnly: false,
},
});
expect(getByTestId('parameter-options-container')).toBeInTheDocument();
expect(getByTestId('action-toggle')).toBeInTheDocument();
expect(getByTestId('radio-button-fixed')).toBeInTheDocument();
expect(getByTestId('radio-button-expression')).toBeInTheDocument();
});
it("doesn't render expression with showExpression set to false", () => {
const { getByTestId, queryByTestId, container } = renderComponent(ParameterOptions, {
props: {
parameter: DEFAULT_PARAMETER,
isReadOnly: false,
showExpressionSelector: false,
value: 'manual',
},
});
expect(getByTestId('parameter-options-container')).toBeInTheDocument();
expect(getByTestId('action-toggle')).toBeInTheDocument();
expect(queryByTestId('radio-button-fixed')).not.toBeInTheDocument();
expect(queryByTestId('radio-button-expression')).not.toBeInTheDocument();
expect(container.querySelector('.noExpressionSelector')).toBeInTheDocument();
});
it('should render loading state', () => {
const CUSTOM_LOADING_MESSAGE = 'Loading...';
const { getByTestId, getByText } = renderComponent(ParameterOptions, {
props: {
parameter: DEFAULT_PARAMETER,
isReadOnly: false,
showExpressionSelector: false,
value: 'manual',
loading: true,
loadingMessage: CUSTOM_LOADING_MESSAGE,
},
});
expect(getByTestId('parameter-options-loader')).toBeInTheDocument();
expect(getByText(CUSTOM_LOADING_MESSAGE)).toBeInTheDocument();
});
it('should render horizontal icon', () => {
const { container } = renderComponent(ParameterOptions, {
props: {
parameter: DEFAULT_PARAMETER,
value: 'manual',
isReadOnly: false,
iconOrientation: 'horizontal',
},
});
expect(container.querySelector('[data-icon="ellipsis-h"]')).toBeInTheDocument();
});
it('should render custom actions', async () => {
const CUSTOM_ACTIONS = [
{ label: 'Action 1', value: 'action1' },
{ label: 'Action 2', value: 'action2' },
];
const { getByTestId } = renderComponent(ParameterOptions, {
props: {
parameter: DEFAULT_PARAMETER,
value: 'manual',
isReadOnly: false,
customActions: CUSTOM_ACTIONS,
},
});
const actionToggle = getByTestId('action-toggle');
const actionToggleButton = within(actionToggle).getByRole('button');
expect(actionToggleButton).toBeVisible();
await userEvent.click(actionToggle);
const actionToggleId = actionToggleButton.getAttribute('aria-controls');
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
expect(actionDropdown).toBeInTheDocument();
// All custom actions should be rendered
CUSTOM_ACTIONS.forEach((action) => {
expect(within(actionDropdown).getByText(action.label)).toBeInTheDocument();
});
});
it('should emit update:modelValue when changing to expression', async () => {
const { emitted, getByTestId } = renderComponent(ParameterOptions, {
props: {
parameter: DEFAULT_PARAMETER,
value: 'manual',
isReadOnly: false,
},
});
expect(getByTestId('radio-button-expression')).toBeInTheDocument();
await userEvent.click(getByTestId('radio-button-expression'));
await waitFor(() => expect(emitted('update:modelValue')).toEqual([['addExpression']]));
});
it('should emit update:modelValue when changing to fixed', async () => {
const { emitted, getByTestId } = renderComponent(ParameterOptions, {
props: {
parameter: DEFAULT_PARAMETER,
value: '=manual',
isReadOnly: false,
},
});
expect(getByTestId('radio-button-fixed')).toBeInTheDocument();
await userEvent.click(getByTestId('radio-button-fixed'));
await waitFor(() => expect(emitted('update:modelValue')).toEqual([['removeExpression']]));
});
});

View file

@ -1,169 +1,134 @@
<script lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow'; import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow';
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isResourceLocatorValue } from '@/utils/typeGuards';
import { isValueExpression } from '@/utils/nodeTypesUtils'; import { isValueExpression } from '@/utils/nodeTypesUtils';
import { i18n } from '@/plugins/i18n'; import { computed } from 'vue';
export default defineComponent({ interface Props {
name: 'ParameterOptions', parameter: INodeProperties;
props: { isReadOnly: boolean;
parameter: { value: NodeParameterValueType;
type: Object as PropType<INodeProperties>, showOptions?: boolean;
required: true, showExpressionSelector?: boolean;
}, customActions?: Array<{ label: string; value: string; disabled?: boolean }>;
isReadOnly: { iconOrientation?: 'horizontal' | 'vertical';
type: Boolean, loading?: boolean;
}, loadingMessage?: string;
value: { }
type: [Object, String, Number, Boolean, Array] as PropType<NodeParameterValueType>,
},
showOptions: {
type: Boolean,
default: true,
},
showExpressionSelector: {
type: Boolean,
default: true,
},
customActions: {
type: Array as PropType<Array<{ label: string; value: string; disabled?: boolean }>>,
default: () => [],
},
iconOrientation: {
type: String,
default: 'vertical',
validator: (value: string): boolean => ['horizontal', 'vertical'].includes(value),
},
loading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default() {
return i18n.baseText('genericHelpers.loading');
},
},
},
emits: ['update:modelValue', 'menu-expanded'],
computed: {
isDefault(): boolean {
return this.parameter.default === this.value;
},
isValueExpression(): boolean {
return isValueExpression(this.parameter, this.value);
},
isHtmlEditor(): boolean {
return this.getArgument('editor') === 'htmlEditor';
},
shouldShowExpressionSelector(): boolean {
return this.parameter.noDataExpression !== true && this.showExpressionSelector;
},
shouldShowOptions(): boolean {
if (this.isReadOnly) {
return false;
}
if (this.parameter.type === 'collection' || this.parameter.type === 'credentialsSelect') { const props = withDefaults(defineProps<Props>(), {
return false; showOptions: true,
} showExpressionSelector: true,
customActions: () => [],
if (['codeNodeEditor', 'sqlEditor'].includes(this.parameter.typeOptions?.editor ?? '')) { iconOrientation: 'vertical',
return false; loading: false,
} loadingMessage: () => useI18n().baseText('genericHelpers.loading'),
if (this.showOptions) {
return true;
}
return false;
},
selectedView() {
if (this.isValueExpression) {
return 'expression';
}
return 'fixed';
},
hasRemoteMethod(): boolean {
return !!this.getArgument('loadOptionsMethod') || !!this.getArgument('loadOptions');
},
actions(): Array<{ label: string; value: string; disabled?: boolean }> {
if (Array.isArray(this.customActions) && this.customActions.length > 0) {
return this.customActions;
}
if (this.isHtmlEditor && !this.isValueExpression) {
return [
{
label: this.$locale.baseText('parameterInput.formatHtml'),
value: 'formatHtml',
},
];
}
const actions = [
{
label: this.$locale.baseText('parameterInput.resetValue'),
value: 'resetValue',
disabled: this.isDefault,
},
];
if (
this.hasRemoteMethod ||
(this.parameter.type === 'resourceLocator' &&
isResourceLocatorValue(this.value) &&
this.value.mode === 'list')
) {
return [
{
label: this.$locale.baseText('parameterInput.refreshList'),
value: 'refreshOptions',
},
...actions,
];
}
return actions;
},
},
methods: {
onMenuToggle(visible: boolean) {
this.$emit('menu-expanded', visible);
},
onViewSelected(selected: string) {
if (selected === 'expression') {
this.$emit(
'update:modelValue',
this.isValueExpression ? 'openExpression' : 'addExpression',
);
}
if (selected === 'fixed' && this.isValueExpression) {
this.$emit('update:modelValue', 'removeExpression');
}
},
getArgument(argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
}
if (this.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return this.parameter.typeOptions[argumentName];
},
},
}); });
const emit = defineEmits<{
'update:modelValue': [value: string];
'menu-expanded': [visible: boolean];
}>();
const i18n = useI18n();
const isDefault = computed(() => props.parameter.default === props.value);
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
const isHtmlEditor = computed(() => getArgument('editor') === 'htmlEditor');
const shouldShowExpressionSelector = computed(
() => !props.parameter.noDataExpression && props.showExpressionSelector,
);
const shouldShowOptions = computed(() => {
if (props.isReadOnly) {
return false;
}
if (props.parameter.type === 'collection' || props.parameter.type === 'credentialsSelect') {
return false;
}
if (['codeNodeEditor', 'sqlEditor'].includes(props.parameter.typeOptions?.editor ?? '')) {
return false;
}
if (props.showOptions) {
return true;
}
return false;
});
const selectedView = computed(() => (isValueAnExpression.value ? 'expression' : 'fixed'));
const hasRemoteMethod = computed(
() =>
!!props.parameter.typeOptions?.loadOptionsMethod || !!props.parameter.typeOptions?.loadOptions,
);
const actions = computed(() => {
if (Array.isArray(props.customActions) && props.customActions.length > 0) {
return props.customActions;
}
if (isHtmlEditor.value && !isValueAnExpression.value) {
return [
{
label: i18n.baseText('parameterInput.formatHtml'),
value: 'formatHtml',
},
];
}
const parameterActions = [
{
label: i18n.baseText('parameterInput.resetValue'),
value: 'resetValue',
disabled: isDefault.value,
},
];
if (
hasRemoteMethod.value ||
(props.parameter.type === 'resourceLocator' &&
isResourceLocatorValue(props.value) &&
props.value.mode === 'list')
) {
return [
{
label: i18n.baseText('parameterInput.refreshList'),
value: 'refreshOptions',
},
...parameterActions,
];
}
return parameterActions;
});
const onMenuToggle = (visible: boolean) => emit('menu-expanded', visible);
const onViewSelected = (selected: string) => {
if (selected === 'expression') {
emit('update:modelValue', isValueAnExpression.value ? 'openExpression' : 'addExpression');
}
if (selected === 'fixed' && isValueAnExpression.value) {
emit('update:modelValue', 'removeExpression');
}
};
const getArgument = (argumentName: string) => {
if (props.parameter.typeOptions === undefined) {
return undefined;
}
if (props.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return props.parameter.typeOptions[argumentName];
};
</script> </script>
<template> <template>
<div :class="$style.container"> <div :class="$style.container" data-test-id="parameter-options-container">
<div v-if="loading" :class="$style.loader"> <div v-if="loading" :class="$style.loader" data-test-id="parameter-options-loader">
<n8n-text v-if="loading" size="small"> <n8n-text v-if="loading" size="small">
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" /> <n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
{{ loadingMessage }} {{ loadingMessage }}
@ -193,8 +158,8 @@ export default defineComponent({
:model-value="selectedView" :model-value="selectedView"
:disabled="isReadOnly" :disabled="isReadOnly"
:options="[ :options="[
{ label: $locale.baseText('parameterInput.fixed'), value: 'fixed' }, { label: i18n.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: $locale.baseText('parameterInput.expression'), value: 'expression' }, { label: i18n.baseText('parameterInput.expression'), value: 'expression' },
]" ]"
@update:model-value="onViewSelected" @update:model-value="onViewSelected"
/> />

View file

@ -5,11 +5,11 @@ import { createRouter, createMemoryHistory, useRouter } from 'vue-router';
import { createProjectListItem } from '@/__tests__/data/projects'; import { createProjectListItem } from '@/__tests__/data/projects';
import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue'; import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useUIStore } from '@/stores/ui.store';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import type { Project } from '@/types/projects.types'; import type { Project } from '@/types/projects.types';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
vi.mock('vue-router', async () => { vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router'); const actual = await vi.importActual('vue-router');
@ -36,6 +36,15 @@ vi.mock('@/composables/useToast', () => {
}; };
}); });
vi.mock('@/composables/usePageRedirectionHelper', () => {
const goToUpgrade = vi.fn();
return {
usePageRedirectionHelper: () => ({
goToUpgrade,
}),
};
});
const renderComponent = createComponentRenderer(ProjectsNavigation, { const renderComponent = createComponentRenderer(ProjectsNavigation, {
global: { global: {
plugins: [ plugins: [
@ -56,7 +65,7 @@ const renderComponent = createComponentRenderer(ProjectsNavigation, {
let router: ReturnType<typeof useRouter>; let router: ReturnType<typeof useRouter>;
let toast: ReturnType<typeof useToast>; let toast: ReturnType<typeof useToast>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>; let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>; let pageRedirectionHelper: ReturnType<typeof usePageRedirectionHelper>;
const personalProjects = Array.from({ length: 3 }, createProjectListItem); const personalProjects = Array.from({ length: 3 }, createProjectListItem);
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team')); const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
@ -67,9 +76,9 @@ describe('ProjectsNavigation', () => {
router = useRouter(); router = useRouter();
toast = useToast(); toast = useToast();
pageRedirectionHelper = usePageRedirectionHelper();
projectsStore = mockedStore(useProjectsStore); projectsStore = mockedStore(useProjectsStore);
uiStore = mockedStore(useUIStore);
}); });
it('should not throw an error', () => { it('should not throw an error', () => {
@ -144,7 +153,7 @@ describe('ProjectsNavigation', () => {
expect(getByText(/You have reached the Free plan limit of 3/)).toBeVisible(); expect(getByText(/You have reached the Free plan limit of 3/)).toBeVisible();
await userEvent.click(getByText('View plans')); await userEvent.click(getByText('View plans'));
expect(uiStore.goToUpgrade).toHaveBeenCalledWith('rbac', 'upgrade-rbac'); expect(pageRedirectionHelper.goToUpgrade).toHaveBeenCalledWith('rbac', 'upgrade-rbac');
}); });
it('should show "Projects" title and Personal project when the feature is enabled', async () => { it('should show "Projects" title and Personal project when the feature is enabled', async () => {

View file

@ -7,8 +7,8 @@ import { VIEWS } from '@/constants';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectListItem } from '@/types/projects.types'; import type { ProjectListItem } from '@/types/projects.types';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { sortByProperty } from '@/utils/sortUtils'; import { sortByProperty } from '@/utils/sortUtils';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
type Props = { type Props = {
collapsed: boolean; collapsed: boolean;
@ -21,7 +21,7 @@ const router = useRouter();
const locale = useI18n(); const locale = useI18n();
const toast = useToast(); const toast = useToast();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const uiStore = useUIStore(); const pageRedirectionHelper = usePageRedirectionHelper();
const isCreatingProject = ref(false); const isCreatingProject = ref(false);
const isComponentMounted = ref(false); const isComponentMounted = ref(false);
@ -99,7 +99,7 @@ const canCreateProjects = computed(
); );
const goToUpgrade = async () => { const goToUpgrade = async () => {
await uiStore.goToUpgrade('rbac', 'upgrade-rbac'); await pageRedirectionHelper.goToUpgrade('rbac', 'upgrade-rbac');
}; };
onMounted(async () => { onMounted(async () => {

View file

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
type Props = { type Props = {
limit: number; limit: number;
@ -9,11 +9,11 @@ type Props = {
const props = defineProps<Props>(); const props = defineProps<Props>();
const visible = defineModel<boolean>(); const visible = defineModel<boolean>();
const uiStore = useUIStore(); const pageRedirectionHelper = usePageRedirectionHelper();
const locale = useI18n(); const locale = useI18n();
const goToUpgrade = async () => { const goToUpgrade = async () => {
await uiStore.goToUpgrade('rbac', 'upgrade-rbac'); await pageRedirectionHelper.goToUpgrade('rbac', 'upgrade-rbac');
visible.value = false; visible.value = false;
}; };
</script> </script>

View file

@ -2,7 +2,7 @@ import {
DEFAULT_SETUP, DEFAULT_SETUP,
MAPPING_COLUMNS_RESPONSE, MAPPING_COLUMNS_RESPONSE,
UPDATED_SCHEMA, UPDATED_SCHEMA,
} from './utils/ResourceMapper.utils'; } from './__tests__/utils/ResourceMapper.utils';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { cleanupAppModals, createAppModals, waitAllPromises } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, waitAllPromises } from '@/__tests__/utils';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';

View file

@ -304,6 +304,7 @@ defineExpose({
:loading="props.refreshInProgress" :loading="props.refreshInProgress"
:loading-message="fetchingFieldsLabel" :loading-message="fetchingFieldsLabel"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:value="props.paramValue"
@update:model-value="onParameterActionSelected" @update:model-value="onParameterActionSelected"
/> />
</template> </template>

View file

@ -187,6 +187,7 @@ defineExpose({
:loading="props.refreshInProgress" :loading="props.refreshInProgress"
:loading-message="fetchingFieldsLabel" :loading-message="fetchingFieldsLabel"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:value="state.selected"
@update:model-value="onParameterActionSelected" @update:model-value="onParameterActionSelected"
/> />
</template> </template>

View file

@ -162,6 +162,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
hidePagination: {
type: Boolean,
default: false,
},
}, },
setup(props) { setup(props) {
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
@ -1743,6 +1747,7 @@ export default defineComponent({
</div> </div>
<div <div
v-if=" v-if="
hidePagination === false &&
hasNodeRun && hasNodeRun &&
!hasRunError && !hasRunError &&
displayMode !== 'binary' && displayMode !== 'binary' &&

View file

@ -1,8 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import type { ITaskSubRunMetadata, ITaskDataConnections } from 'n8n-workflow'; import type { ITaskDataConnections, NodeConnectionType, Workflow, ITaskData } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import type { IAiData, IAiDataContent, INodeUi } from '@/Interface'; import type { IAiData, IAiDataContent, INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
@ -28,29 +27,21 @@ export interface Props {
runIndex?: number; runIndex?: number;
hideTitle?: boolean; hideTitle?: boolean;
slim?: boolean; slim?: boolean;
workflow: Workflow;
} }
const props = withDefaults(defineProps<Props>(), { runIndex: 0 }); const props = withDefaults(defineProps<Props>(), { runIndex: 0 });
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const selectedRun: Ref<IAiData[]> = ref([]); const selectedRun: Ref<IAiData[]> = ref([]);
function isTreeNodeSelected(node: TreeNode) { function isTreeNodeSelected(node: TreeNode) {
return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex); return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex);
} }
function getReferencedData( function getReferencedData(
reference: ITaskSubRunMetadata, taskData: ITaskData,
withInput: boolean, withInput: boolean,
withOutput: boolean, withOutput: boolean,
): IAiDataContent[] { ): IAiDataContent[] {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(reference.node);
if (!resultData?.[reference.runIndex]) {
return [];
}
const taskData = resultData[reference.runIndex];
if (!taskData) { if (!taskData) {
return []; return [];
} }
@ -98,18 +89,18 @@ function onItemClick(data: TreeNode) {
return; return;
} }
const selectedNodeRun = workflowsStore.getWorkflowResultDataByNodeName(data.node)?.[
data.runIndex
];
if (!selectedNodeRun) {
return;
}
selectedRun.value = [ selectedRun.value = [
{ {
node: data.node, node: data.node,
runIndex: data.runIndex, runIndex: data.runIndex,
data: getReferencedData( data: getReferencedData(selectedNodeRun, true, true),
{
node: data.node,
runIndex: data.runIndex,
},
true,
true,
),
}, },
]; ];
} }
@ -145,21 +136,20 @@ const createNode = (
}); });
function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] { function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
const { connectionsByDestinationNode } = workflowsStore.getCurrentWorkflow(); const connections = props.workflow.connectionsByDestinationNode[nodeName];
const connections = connectionsByDestinationNode[nodeName];
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? []; const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? [];
if (!connections) { if (!connections) {
return resultData.map((d) => createNode(nodeName, currentDepth, d)); return resultData.map((d) => createNode(nodeName, currentDepth, d));
} }
const nonMainConnectionsKeys = Object.keys(connections).filter( // Get the first level of children
(key) => key !== NodeConnectionType.Main, const connectedSubNodes = props.workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
);
const children = nonMainConnectionsKeys.flatMap((key) => const children = connectedSubNodes
connections[key][0].flatMap((node) => getTreeNodeData(node.node, currentDepth + 1)), // Only include sub-nodes which have data
); .filter((name) => aiData.value?.find((data) => data.node === name))
.flatMap((name) => getTreeNodeData(name, currentDepth + 1));
children.sort((a, b) => a.startTime - b.startTime); children.sort((a, b) => a.startTime - b.startTime);
@ -170,35 +160,49 @@ function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
return [createNode(nodeName, currentDepth, undefined, children)]; return [createNode(nodeName, currentDepth, undefined, children)];
} }
const aiData = computed<AIResult[] | undefined>(() => { const aiData = computed<AIResult[]>(() => {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(props.node.name); const result: AIResult[] = [];
const connectedSubNodes = props.workflow.getParentNodes(props.node.name, 'ALL_NON_MAIN');
const rootNodeResult = workflowsStore.getWorkflowResultDataByNodeName(props.node.name);
const rootNodeStartTime = rootNodeResult?.[0]?.startTime ?? 0;
const rootNodeEndTime = rootNodeStartTime + (rootNodeResult?.[0]?.executionTime ?? 0);
if (!resultData || !Array.isArray(resultData)) { connectedSubNodes.forEach((nodeName) => {
return; const nodeRunData = workflowsStore.getWorkflowResultDataByNodeName(nodeName) ?? [];
}
const subRun = resultData[props.runIndex].metadata?.subRun; nodeRunData.forEach((run, runIndex) => {
if (!Array.isArray(subRun)) { const referenceData = {
return; data: getReferencedData(run, false, true)[0],
} node: nodeName,
// Extend the subRun with the data and sort by adding execution time + startTime and comparing them runIndex,
const subRunWithData = subRun.flatMap((run) => };
getReferencedData(run, false, true).map((data) => ({ ...run, data })),
);
subRunWithData.sort((a, b) => { result.push(referenceData);
const aTime = a.data?.metadata?.startTime || 0; });
const bTime = b.data?.metadata?.startTime || 0; });
// Sort the data by start time
result.sort((a, b) => {
const aTime = a.data?.metadata?.startTime ?? 0;
const bTime = b.data?.metadata?.startTime ?? 0;
return aTime - bTime; return aTime - bTime;
}); });
return subRunWithData; // Only show data that is within the root node's execution time
// This is because sub-node could be connected to multiple root nodes
const currentNodeResult = result.filter((r) => {
const startTime = r.data?.metadata?.startTime ?? 0;
return startTime >= rootNodeStartTime && startTime <= rootNodeEndTime;
});
return currentNodeResult;
}); });
const executionTree = computed<TreeNode[]>(() => { const executionTree = computed<TreeNode[]>(() => {
const rootNode = props.node; const rootNode = props.node;
const tree = getTreeNodeData(rootNode.name, 0); const tree = getTreeNodeData(rootNode.name, 1);
return tree || []; return tree || [];
}); });
@ -206,7 +210,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
</script> </script>
<template> <template>
<div v-if="aiData" :class="$style.container"> <div v-if="aiData.length > 0" :class="$style.container">
<div :class="{ [$style.tree]: true, [$style.slim]: slim }"> <div :class="{ [$style.tree]: true, [$style.slim]: slim }">
<ElTree <ElTree
:data="executionTree" :data="executionTree"

View file

@ -2,9 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { ABOUT_MODAL_KEY, VIEWS } from '@/constants'; import { ABOUT_MODAL_KEY, VIEWS } from '@/constants';
import { useUserHelpers } from '@/composables/useUserHelpers'; import { useUserHelpers } from '@/composables/useUserHelpers';
import type { IFakeDoor } from '@/Interface';
import type { IMenuItem } from 'n8n-design-system'; import type { IMenuItem } from 'n8n-design-system';
import type { BaseTextKey } from '@/plugins/i18n';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
@ -26,23 +24,6 @@ const rootStore = useRootStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const settingsFakeDoorFeatures = computed<IFakeDoor[]>(() =>
Object.keys(uiStore.fakeDoorsByLocation)
.filter((location: string) => location.includes('settings'))
.map((location) => uiStore.fakeDoorsByLocation[location]),
);
const handleSelect = (key: string) => {
switch (key) {
case 'users': // Fakedoor feature added via hooks when user management is disabled on cloud
case 'logging':
router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {});
break;
default:
break;
}
};
const sidebarMenuItems = computed<IMenuItem[]>(() => { const sidebarMenuItems = computed<IMenuItem[]>(() => {
const menuItems: IMenuItem[] = [ const menuItems: IMenuItem[] = [
{ {
@ -122,19 +103,6 @@ const sidebarMenuItems = computed<IMenuItem[]>(() => {
}, },
]; ];
for (const item of settingsFakeDoorFeatures.value) {
if (item.uiLocations.includes('settings')) {
menuItems.push({
id: item.id,
icon: item.icon ?? 'question',
label: i18n.baseText(item.featureName as BaseTextKey),
position: 'top',
available: true,
activateOnRoutePaths: [`/settings/coming-soon/${item.id}`],
});
}
}
menuItems.push({ menuItems.push({
id: 'settings-log-streaming', id: 'settings-log-streaming',
icon: 'sign-in-alt', icon: 'sign-in-alt',
@ -159,7 +127,7 @@ const sidebarMenuItems = computed<IMenuItem[]>(() => {
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<n8n-menu :items="sidebarMenuItems" @select="handleSelect"> <n8n-menu :items="sidebarMenuItems">
<template #header> <template #header>
<div :class="$style.returnButton" data-test-id="settings-back" @click="emit('return')"> <div :class="$style.returnButton" data-test-id="settings-back" @click="emit('return')">
<i class="mr-xs"> <i class="mr-xs">

View file

@ -1,82 +1,75 @@
<script lang="ts"> <script lang="ts" setup>
import { type PropType, defineComponent } from 'vue'; import { onBeforeUnmount, onMounted, ref } from 'vue';
import TemplateCard from './TemplateCard.vue'; import TemplateCard from './TemplateCard.vue';
import type { ITemplatesWorkflow } from '@/Interface'; import type { ITemplatesWorkflow } from '@/Interface';
export default defineComponent({ interface Props {
name: 'TemplateList', workflows?: ITemplatesWorkflow[];
components: { infiniteScrollEnabled?: boolean;
TemplateCard, loading?: boolean;
}, useWorkflowButton?: boolean;
props: { totalWorkflows?: number;
infiniteScrollEnabled: { simpleView?: boolean;
type: Boolean, totalCount?: number;
default: false, }
},
loading: { const emit = defineEmits<{
type: Boolean, loadMore: [];
}, openTemplate: [{ event: MouseEvent; id: number }];
useWorkflowButton: { useWorkflow: [{ event: MouseEvent; id: number }];
type: Boolean, }>();
default: false,
}, const props = withDefaults(defineProps<Props>(), {
workflows: { infiniteScrollEnabled: false,
type: Array as PropType<ITemplatesWorkflow[]>, loading: false,
default: () => [], useWorkflowButton: false,
}, workflows: () => [],
totalWorkflows: { totalWorkflows: 0,
type: Number, simpleView: false,
default: 0, totalCount: 0,
}, });
simpleView: {
type: Boolean, const loader = ref<HTMLElement | null>(null);
default: false,
}, onMounted(() => {
totalCount: { if (props.infiniteScrollEnabled) {
type: Number,
default: 0,
},
},
mounted() {
if (this.infiniteScrollEnabled) {
const content = document.getElementById('content');
if (content) {
content.addEventListener('scroll', this.onScroll);
}
}
},
beforeUnmount() {
const content = document.getElementById('content'); const content = document.getElementById('content');
if (content) { if (content) {
content.removeEventListener('scroll', this.onScroll); content.addEventListener('scroll', onScroll);
} }
}, }
methods: {
onScroll() {
const loaderRef = this.$refs.loader as HTMLElement | undefined;
if (!loaderRef || this.loading) {
return;
}
const rect = loaderRef.getBoundingClientRect();
const inView =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth);
if (inView) {
this.$emit('loadMore');
}
},
onCardClick(event: MouseEvent, id: number) {
this.$emit('openTemplate', { event, id });
},
onUseWorkflow(event: MouseEvent, id: number) {
this.$emit('useWorkflow', { event, id });
},
},
}); });
onBeforeUnmount(() => {
const content = document.getElementById('content');
if (content) {
content.removeEventListener('scroll', onScroll);
}
});
function onScroll() {
const loaderRef = loader.value as HTMLElement | undefined;
if (!loaderRef || props.loading) {
return;
}
const rect = loaderRef.getBoundingClientRect();
const inView =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth);
if (inView) {
emit('loadMore');
}
}
function onCardClick(event: MouseEvent, id: number) {
emit('openTemplate', { event, id });
}
function onUseWorkflow(event: MouseEvent, id: number) {
emit('useWorkflow', { event, id });
}
</script> </script>
<template> <template>

View file

@ -1,27 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import ModalDrawer from './ModalDrawer.vue'; import ModalDrawer from './ModalDrawer.vue';
import TimeAgo from './TimeAgo.vue'; import TimeAgo from './TimeAgo.vue';
import VersionCard from './VersionCard.vue'; import VersionCard from './VersionCard.vue';
import { VERSIONS_MODAL_KEY } from '../constants'; import { VERSIONS_MODAL_KEY } from '../constants';
import { useVersionsStore } from '@/stores/versions.store'; import { useVersionsStore } from '@/stores/versions.store';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const versionsStore = useVersionsStore(); const versionsStore = useVersionsStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const i18n = useI18n(); const i18n = useI18n();
const nextVersions = computed(() => {
return versionsStore.nextVersions;
});
const currentVersion = computed(() => {
return versionsStore.currentVersion;
});
const infoUrl = computed(() => {
return versionsStore.infoUrl;
});
</script> </script>
<template> <template>
@ -38,22 +27,22 @@ const infoUrl = computed(() => {
</template> </template>
<template #content> <template #content>
<section :class="$style['description']"> <section :class="$style['description']">
<p v-if="currentVersion"> <p v-if="versionsStore.currentVersion">
{{ {{
i18n.baseText('updatesPanel.youReOnVersion', { i18n.baseText('updatesPanel.youReOnVersion', {
interpolate: { currentVersionName: currentVersion.name }, interpolate: { currentVersionName: versionsStore.currentVersion.name },
}) })
}} }}
<strong> <strong>
<TimeAgo :date="currentVersion.createdAt" /> <TimeAgo :date="versionsStore.currentVersion.createdAt" />
</strong> </strong>
{{ i18n.baseText('updatesPanel.andIs') }} {{ i18n.baseText('updatesPanel.andIs') }}
<strong> <strong>
{{ {{
i18n.baseText('updatesPanel.version', { i18n.baseText('updatesPanel.version', {
interpolate: { interpolate: {
numberOfVersions: nextVersions.length, numberOfVersions: versionsStore.nextVersions.length,
howManySuffix: nextVersions.length > 1 ? 's' : '', howManySuffix: versionsStore.nextVersions.length > 1 ? 's' : '',
}, },
}) })
}} }}
@ -61,15 +50,27 @@ const infoUrl = computed(() => {
{{ i18n.baseText('updatesPanel.behindTheLatest') }} {{ i18n.baseText('updatesPanel.behindTheLatest') }}
</p> </p>
<n8n-link v-if="infoUrl" :to="infoUrl" :bold="true"> <n8n-button
v-if="versionsStore.infoUrl"
:text="true"
type="primary"
size="large"
:class="$style['link']"
:bold="true"
@click="pageRedirectionHelper.goToVersions()"
>
<font-awesome-icon icon="info-circle" class="mr-2xs" /> <font-awesome-icon icon="info-circle" class="mr-2xs" />
<span> <span>
{{ i18n.baseText('updatesPanel.howToUpdateYourN8nVersion') }} {{ i18n.baseText('updatesPanel.howToUpdateYourN8nVersion') }}
</span> </span>
</n8n-link> </n8n-button>
</section> </section>
<section :class="$style.versions"> <section :class="$style.versions">
<div v-for="version in nextVersions" :key="version.name" :class="$style['versions-card']"> <div
v-for="version in versionsStore.nextVersions"
:key="version.name"
:class="$style['versions-card']"
>
<VersionCard :version="version" /> <VersionCard :version="version" />
</div> </div>
</section> </section>
@ -102,6 +103,15 @@ const infoUrl = computed(() => {
div { div {
padding-top: 20px; padding-top: 20px;
} }
.link {
padding-left: 0px;
}
.link:hover {
color: var(--prim-color-primary);
text-decoration: none;
}
} }
.versions { .versions {

View file

@ -1,4 +1,4 @@
import VariablesRow from '../VariablesRow.vue'; import VariablesRow from './VariablesRow.vue';
import { fireEvent } from '@testing-library/vue'; import { fireEvent } from '@testing-library/vue';
import { setupServer } from '@/__tests__/server'; import { setupServer } from '@/__tests__/server';
import { afterAll, beforeAll } from 'vitest'; import { afterAll, beforeAll } from 'vitest';

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