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)
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)

View file

@ -40,6 +40,14 @@ export function getOutputPanelDataContainer() {
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() {
return getOutputPanelDataContainer().get('table');
}

View file

@ -69,6 +69,13 @@ export function getNodeCreatorPlusButton() {
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
*/

View file

@ -90,6 +90,14 @@ function createRunDataWithError(inputMessage: string) {
routine: 'InitPostgres',
} as unknown as Error,
} as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}),
createMockNodeExecutionData(AGENT_NODE_NAME, {
executionStatus: 'error',
@ -124,14 +132,6 @@ function createRunDataWithError(inputMessage: string) {
description: 'Internal error',
message: 'Internal error',
} 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: {
ai_languageModel: [
[
@ -316,9 +319,6 @@ describe('Langchain Integration', () => {
jsonData: {
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,

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 { errorToast } from '../pages/notifications';
import { WorkflowPage } from '../pages/workflow';
const workflowPage = new WorkflowPage();
describe('Demo', () => {
beforeEach(() => {
cy.overrideSettings({ previewMode: true });
cy.signout();
});
it('can import template', () => {
visitDemoPage();
errorToast().should('not.exist');
importWorkflow(workflow);
workflowPage.getters.canvasNodes().should('have.length', 3);
importWorkflow(SIMPLE_WORKFLOW);
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', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -65,6 +65,42 @@ describe('Publisher', () => {
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', () => {

View file

@ -1,3 +1,5 @@
import type { PubSub } from './pubsub/pubsub.types';
export const QUEUE_NAME = 'jobs';
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
* 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',
'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
* multi-main setup.
*/
export const IMMEDIATE_COMMANDS = new Set([
export const IMMEDIATE_COMMANDS = new Set<PubSub.Command['command']>([
'add-webhooks-triggers-and-pollers',
'remove-triggers-and-pollers',
'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 */
getWebhookBaseUrl() {
let urlBaseWebhook = process.env.WEBHOOK_URL ?? this.baseUrl;
let urlBaseWebhook = this.trimQuotes(process.env.WEBHOOK_URL) || this.baseUrl;
if (!urlBaseWebhook.endsWith('/')) {
urlBaseWebhook += '/';
}
@ -23,7 +23,7 @@ export class UrlService {
/** Return the n8n instance base URL without trailing slash */
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;
}
@ -36,4 +36,9 @@ export class UrlService {
}
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",
"version": "1.65.0",
"version": "1.66.0",
"description": "Core functionality of n8n",
"main": "dist/index",
"types": "dist/index.d.ts",

View file

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

View file

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

View file

@ -52,7 +52,6 @@ import type {
AI_NODE_CREATOR_VIEW,
CREDENTIAL_EDIT_MODAL_KEY,
SignInType,
FAKE_DOOR_FEATURES,
TRIGGER_NODE_CREATOR_VIEW,
REGULAR_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 { ProjectSharingData } from '@/types/projects.types';
import type { BaseTextKey } from './plugins/i18n';
export * from 'n8n-design-system/types';
@ -1036,24 +1034,6 @@ export interface NotificationOptions extends Partial<ElementNotificationOptions>
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 =
| typeof REGULAR_NODE_CREATOR_VIEW
| typeof TRIGGER_NODE_CREATOR_VIEW

View file

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

View file

@ -4,7 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { fireEvent, within } from '@testing-library/vue';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import AssignmentCollection from '../AssignmentCollection.vue';
import AssignmentCollection from './AssignmentCollection.vue';
import { STORES } from '@/constants';
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: 'arrayKey', value: [], dropArea });
let assignments = await findAllByTestId('assignment');
const assignments = await findAllByTestId('assignment');
expect(assignments.length).toBe(5);
expect(getAssignmentType(assignments[0])).toEqual('Boolean');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import DropArea from '../DropArea.vue';
import DropArea from './DropArea.vue';
const renderComponent = createComponentRenderer(DropArea, {
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 userEvent from '@testing-library/user-event';
import { within, waitFor } from '@testing-library/vue';
import { getFilterOperator } from '../utils';
import { getFilterOperator } from './utils';
import { get } from 'lodash-es';
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('handleOperatorChange', () => {

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { createFormEventBus, createEventBus } from 'n8n-design-system/utils';
import { useClipboard } from '@/composables/useClipboard';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
@ -43,6 +44,7 @@ export default defineComponent({
return {
clipboard,
...useToast(),
...usePageRedirectionHelper(),
};
},
data() {
@ -277,7 +279,7 @@ export default defineComponent({
}
},
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 { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { useNodeViewVersionSwitcher } from '@/composables/useNodeViewVersionSwitcher';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = defineProps<{
readOnly?: boolean;
@ -89,6 +90,7 @@ const message = useMessage();
const toast = useToast();
const documentTitle = useDocumentTitle();
const workflowHelpers = useWorkflowHelpers({ router });
const pageRedirectionHelper = usePageRedirectionHelper();
const isTagsEditEnabled = ref(false);
const isNameEditEnabled = ref(false);
@ -584,11 +586,11 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
}
function goToUpgrade() {
void uiStore.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing');
void pageRedirectionHelper.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing');
}
function goToWorkflowHistoryUpgrade() {
void uiStore.goToUpgrade('workflow-history', 'upgrade-workflow-history');
void pageRedirectionHelper.goToUpgrade('workflow-history', 'upgrade-workflow-history');
}
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 { useBugReporting } from '@/composables/useBugReporting';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
const cloudPlanStore = useCloudPlanStore();
@ -38,6 +39,7 @@ const locale = useI18n();
const route = useRoute();
const router = useRouter();
const telemetry = useTelemetry();
const pageRedirectionHelper = usePageRedirectionHelper();
const { getReportingURL } = useBugReporting();
useUserHelpers(router, route);
@ -260,7 +262,7 @@ const handleSelect = (key: string) => {
break;
}
case 'cloud-admin': {
void cloudPlanStore.redirectToDashboard();
void pageRedirectionHelper.goToDashboard();
break;
}
case 'quickstart':

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'
import type { IDataObject } from 'n8n-workflow';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
const emit = defineEmits<{
nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]];
@ -47,6 +48,8 @@ const {
actionsCategoryLocales,
} = useActions();
const nodeCreatorStore = useNodeCreatorStore();
// We only inject labels if search is empty
const parsedTriggerActions = computed(() =>
parseActions(actions.value, actionsCategoryLocales.value.triggers, false),
@ -182,7 +185,7 @@ function trackActionsView() {
};
void useExternalHooks().run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
nodeCreatorStore.onViewActions(trackingPayload);
}
function resetSearch() {
@ -206,7 +209,7 @@ function addHttpNode() {
void useExternalHooks().run('nodeCreateList.onActionsCustmAPIClicked', {
app_identifier,
});
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
nodeCreatorStore.onActionsCustomAPIClicked({ app_identifier });
}
// 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 NoResults from '../Panel/NoResults.vue';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store';
@ -36,11 +35,10 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const telemetry = useTelemetry();
const uiStore = useUIStore();
const rootStore = useRootStore();
const { mergedNodes, actions } = useNodeCreatorStore();
const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
const { pushViewStack, popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
@ -83,7 +81,7 @@ function onSelected(item: INodeCreateElement) {
sections: item.properties.sections,
});
telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
onSubcategorySelected({
subcategory: item.key,
});
}
@ -153,9 +151,6 @@ function onSelected(item: INodeCreateElement) {
if (item.type === 'link') {
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 type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { mockSimplifiedNodeType } from './utils';
import NodesListPanel from '../Panel/NodesListPanel.vue';
import { mockSimplifiedNodeType } from './__tests__/utils';
import NodesListPanel from './Panel/NodesListPanel.vue';
import { REGULAR_NODE_CREATOR_VIEW } from '@/constants';
import type { NodeFilterType } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render';

View file

@ -17,12 +17,15 @@ import SearchBar from './SearchBar.vue';
import ActionsRenderer from '../Modes/ActionsMode.vue';
import NodesRenderer from '../Modes/NodesMode.vue';
import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce';
const i18n = useI18n();
const { callDebounced } = useDebounce();
const { mergedNodes } = useNodeCreatorStore();
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
const nodeCreatorStore = useNodeCreatorStore();
const activeViewStack = computed(() => useViewStacks().activeViewStack);
@ -55,6 +58,19 @@ function onSearch(value: string) {
if (activeViewStack.value.uuid) {
updateCurrentViewStack({ search: 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);
font-size: var(--font-size-s);
line-height: 19px;
color: var(--color-text-base);
font-weight: var(--font-weight-regular);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import { createTestingPinia } from '@pinia/testing';
import { faker } from '@faker-js/faker';
import { waitFor } from '@testing-library/vue';
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';
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 { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { isResourceLocatorValue } from '@/utils/typeGuards';
import { isValueExpression } from '@/utils/nodeTypesUtils';
import { i18n } from '@/plugins/i18n';
import { computed } from 'vue';
export default defineComponent({
name: 'ParameterOptions',
props: {
parameter: {
type: Object as PropType<INodeProperties>,
required: true,
},
isReadOnly: {
type: Boolean,
},
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;
}
interface Props {
parameter: INodeProperties;
isReadOnly: boolean;
value: NodeParameterValueType;
showOptions?: boolean;
showExpressionSelector?: boolean;
customActions?: Array<{ label: string; value: string; disabled?: boolean }>;
iconOrientation?: 'horizontal' | 'vertical';
loading?: boolean;
loadingMessage?: string;
}
if (this.parameter.type === 'collection' || this.parameter.type === 'credentialsSelect') {
return false;
}
if (['codeNodeEditor', 'sqlEditor'].includes(this.parameter.typeOptions?.editor ?? '')) {
return false;
}
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 props = withDefaults(defineProps<Props>(), {
showOptions: true,
showExpressionSelector: true,
customActions: () => [],
iconOrientation: 'vertical',
loading: false,
loadingMessage: () => useI18n().baseText('genericHelpers.loading'),
});
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>
<template>
<div :class="$style.container">
<div v-if="loading" :class="$style.loader">
<div :class="$style.container" data-test-id="parameter-options-container">
<div v-if="loading" :class="$style.loader" data-test-id="parameter-options-loader">
<n8n-text v-if="loading" size="small">
<n8n-icon icon="sync-alt" size="xsmall" :spin="true" />
{{ loadingMessage }}
@ -193,8 +158,8 @@ export default defineComponent({
:model-value="selectedView"
:disabled="isReadOnly"
:options="[
{ label: $locale.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: $locale.baseText('parameterInput.expression'), value: 'expression' },
{ label: i18n.baseText('parameterInput.fixed'), value: 'fixed' },
{ label: i18n.baseText('parameterInput.expression'), value: 'expression' },
]"
@update:model-value="onViewSelected"
/>

View file

@ -5,11 +5,11 @@ import { createRouter, createMemoryHistory, useRouter } from 'vue-router';
import { createProjectListItem } from '@/__tests__/data/projects';
import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue';
import { useProjectsStore } from '@/stores/projects.store';
import { useUIStore } from '@/stores/ui.store';
import { mockedStore } from '@/__tests__/utils';
import type { Project } from '@/types/projects.types';
import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
vi.mock('vue-router', async () => {
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, {
global: {
plugins: [
@ -56,7 +65,7 @@ const renderComponent = createComponentRenderer(ProjectsNavigation, {
let router: ReturnType<typeof useRouter>;
let toast: ReturnType<typeof useToast>;
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 teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
@ -67,9 +76,9 @@ describe('ProjectsNavigation', () => {
router = useRouter();
toast = useToast();
pageRedirectionHelper = usePageRedirectionHelper();
projectsStore = mockedStore(useProjectsStore);
uiStore = mockedStore(useUIStore);
});
it('should not throw an error', () => {
@ -144,7 +153,7 @@ describe('ProjectsNavigation', () => {
expect(getByText(/You have reached the Free plan limit of 3/)).toBeVisible();
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 () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,9 +2,7 @@
import { computed } from 'vue';
import { ABOUT_MODAL_KEY, VIEWS } from '@/constants';
import { useUserHelpers } from '@/composables/useUserHelpers';
import type { IFakeDoor } from '@/Interface';
import type { IMenuItem } from 'n8n-design-system';
import type { BaseTextKey } from '@/plugins/i18n';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store';
@ -26,23 +24,6 @@ const rootStore = useRootStore();
const settingsStore = useSettingsStore();
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 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({
id: 'settings-log-streaming',
icon: 'sign-in-alt',
@ -159,7 +127,7 @@ const sidebarMenuItems = computed<IMenuItem[]>(() => {
<template>
<div :class="$style.container">
<n8n-menu :items="sidebarMenuItems" @select="handleSelect">
<n8n-menu :items="sidebarMenuItems">
<template #header>
<div :class="$style.returnButton" data-test-id="settings-back" @click="emit('return')">
<i class="mr-xs">

View file

@ -1,82 +1,75 @@
<script lang="ts">
import { type PropType, defineComponent } from 'vue';
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import TemplateCard from './TemplateCard.vue';
import type { ITemplatesWorkflow } from '@/Interface';
export default defineComponent({
name: 'TemplateList',
components: {
TemplateCard,
},
props: {
infiniteScrollEnabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
},
useWorkflowButton: {
type: Boolean,
default: false,
},
workflows: {
type: Array as PropType<ITemplatesWorkflow[]>,
default: () => [],
},
totalWorkflows: {
type: Number,
default: 0,
},
simpleView: {
type: Boolean,
default: false,
},
totalCount: {
type: Number,
default: 0,
},
},
mounted() {
if (this.infiniteScrollEnabled) {
const content = document.getElementById('content');
if (content) {
content.addEventListener('scroll', this.onScroll);
}
}
},
beforeUnmount() {
interface Props {
workflows?: ITemplatesWorkflow[];
infiniteScrollEnabled?: boolean;
loading?: boolean;
useWorkflowButton?: boolean;
totalWorkflows?: number;
simpleView?: boolean;
totalCount?: number;
}
const emit = defineEmits<{
loadMore: [];
openTemplate: [{ event: MouseEvent; id: number }];
useWorkflow: [{ event: MouseEvent; id: number }];
}>();
const props = withDefaults(defineProps<Props>(), {
infiniteScrollEnabled: false,
loading: false,
useWorkflowButton: false,
workflows: () => [],
totalWorkflows: 0,
simpleView: false,
totalCount: 0,
});
const loader = ref<HTMLElement | null>(null);
onMounted(() => {
if (props.infiniteScrollEnabled) {
const content = document.getElementById('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>
<template>

View file

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

View file

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

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