From d8433d289543c40854e59b0384be356a3d7b947d Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Mon, 24 Mar 2025 09:59:32 +0100 Subject: [PATCH 01/33] feat(API): Implement compaction logic for insights (#14062) Co-authored-by: Danny Martini --- .../__tests__/insights.service.test.ts | 361 +++++++++++++++++- .../insights/entities/__tests__/db-utils.ts | 50 +++ .../src/modules/insights/insights.config.ts | 18 + .../src/modules/insights/insights.service.ts | 77 +++- .../insights-by-period.repository.ts | 155 +++++++- .../repositories/insights-raw.repository.ts | 14 + 6 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/modules/insights/insights.config.ts diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts index 9b84cbea6ad..f6010511d6c 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -14,6 +14,12 @@ import { createTeamProject } from '@test-integration/db/projects'; import { createWorkflow } from '@test-integration/db/workflows'; import * as testDb from '@test-integration/test-db'; +import { + createMetadata, + createRawInsightsEvent, + createCompactedInsightsEvent, + createRawInsightsEvents, +} from '../entities/__tests__/db-utils'; import { InsightsService } from '../insights.service'; import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository'; @@ -30,13 +36,22 @@ async function truncateAll() { } } +// Initialize DB once for all tests +beforeAll(async () => { + jest.useFakeTimers(); + await testDb.init(); +}); + +// Terminate DB once after all tests complete +afterAll(async () => { + await testDb.terminate(); +}); + describe('workflowExecuteAfterHandler', () => { let insightsService: InsightsService; let insightsRawRepository: InsightsRawRepository; let insightsMetadataRepository: InsightsMetadataRepository; beforeAll(async () => { - await testDb.init(); - insightsService = Container.get(InsightsService); insightsRawRepository = Container.get(InsightsRawRepository); insightsMetadataRepository = Container.get(InsightsMetadataRepository); @@ -245,3 +260,345 @@ describe('workflowExecuteAfterHandler', () => { ); }); }); + +describe('compaction', () => { + beforeEach(async () => { + await truncateAll(); + }); + + describe('compactRawToHour', () => { + type TestData = { + name: string; + timestamps: DateTime[]; + batches: number[]; + }; + + test.each([ + { + name: 'compact into 2 rows', + timestamps: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 0, 59), + DateTime.utc(2000, 1, 1, 1, 0), + ], + batches: [2, 1], + }, + { + name: 'compact into 3 rows', + timestamps: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 1, 0), + DateTime.utc(2000, 1, 1, 2, 0), + ], + batches: [1, 1, 1], + }, + ])('$name', async ({ timestamps, batches }) => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + // create before so we can create the raw events in parallel + await createMetadata(workflow); + for (const timestamp of timestamps) { + await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp }); + } + + // ACT + const compactedRows = await insightsService.compactRawToHour(); + + // ASSERT + expect(compactedRows).toBe(timestamps.length); + await expect(insightsRawRepository.count()).resolves.toBe(0); + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + expect(allCompacted).toHaveLength(batches.length); + for (const [index, compacted] of allCompacted.entries()) { + expect(compacted.value).toBe(batches[index]); + } + }); + + test('batch compaction split events in hourly insight periods', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc().startOf('hour'); + for (let i = 0; i < batchSize; i++) { + await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp }); + // create 60 events per hour + timestamp = timestamp.plus({ minute: 1 }); + } + + // ACT + await insightsService.compactInsights(); + + // ASSERT + await expect(insightsRawRepository.count()).resolves.toBe(0); + + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0); + expect(accumulatedValues).toBe(batchSize); + expect(allCompacted[0].value).toBe(60); + expect(allCompacted[1].value).toBe(40); + }); + + test('batch compaction split events in hourly insight periods by type and workflow', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow1 = await createWorkflow({}, project); + const workflow2 = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc().startOf('hour'); + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow1, { type: 'success', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow1, { type: 'failure', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow2, { type: 'runtime_ms', value: 1200, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow2, { type: 'time_saved_min', value: 3, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + // ACT + await insightsService.compactInsights(); + + // ASSERT + await expect(insightsRawRepository.count()).resolves.toBe(0); + + const allCompacted = await insightsByPeriodRepository.find({ + order: { metaId: 'ASC', periodStart: 'ASC' }, + }); + + // Expect 2 insights for workflow 1 (for success and failure) + // and 3 for workflow 2 (2 period starts for runtime_ms and 1 for time_saved_min) + expect(allCompacted).toHaveLength(5); + const metaIds = allCompacted.map((event) => event.metaId); + + // meta id are ordered. first 2 are for workflow 1, last 3 are for workflow 2 + const uniqueMetaIds = [metaIds[0], metaIds[2]]; + const workflow1Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[0]); + const workflow2Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[1]); + + expect(workflow1Insights).toHaveLength(2); + expect(workflow2Insights).toHaveLength(3); + + const successInsights = workflow1Insights.find((event) => event.type === 'success'); + const failureInsights = workflow1Insights.find((event) => event.type === 'failure'); + + expect(successInsights).toBeTruthy(); + expect(failureInsights).toBeTruthy(); + // success and failure insights should have the value matching the number or raw events (because value = 1) + expect(successInsights!.value).toBe(25); + expect(failureInsights!.value).toBe(25); + + const runtimeMsEvents = workflow2Insights.filter((event) => event.type === 'runtime_ms'); + const timeSavedMinEvents = workflow2Insights.find((event) => event.type === 'time_saved_min'); + expect(runtimeMsEvents).toHaveLength(2); + + // The last 10 minutes of the first hour + expect(runtimeMsEvents[0].value).toBe(1200 * 10); + + // The first 15 minutes of the second hour + expect(runtimeMsEvents[1].value).toBe(1200 * 15); + expect(timeSavedMinEvents).toBeTruthy(); + expect(timeSavedMinEvents!.value).toBe(3 * 25); + }); + + test('should return the number of compacted events', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc(2000, 1, 1, 0, 0); + for (let i = 0; i < batchSize; i++) { + await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp }); + // create 60 events per hour + timestamp = timestamp.plus({ minute: 1 }); + } + + // ACT + const numberOfCompactedData = await insightsService.compactRawToHour(); + + // ASSERT + expect(numberOfCompactedData).toBe(100); + }); + + test('works with data in the compacted table', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc().startOf('hour'); + + // Create an existing compacted event for the first hour + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 10, + periodUnit: 'hour', + periodStart: timestamp, + }); + + const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>(); + for (let i = 0; i < batchSize; i++) { + events.push({ type: 'success', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + await createRawInsightsEvents(workflow, events); + + // ACT + await insightsService.compactInsights(); + + // ASSERT + await expect(insightsRawRepository.count()).resolves.toBe(0); + + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0); + expect(accumulatedValues).toBe(batchSize + 10); + expect(allCompacted[0].value).toBe(70); + expect(allCompacted[1].value).toBe(40); + }); + + test('works with data bigger than the batch size', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + // spy on the compactRawToHour method to check if it's called multiple times + const rawToHourSpy = jest.spyOn(insightsService, 'compactRawToHour'); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 600; + + let timestamp = DateTime.utc().startOf('hour'); + const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>(); + for (let i = 0; i < batchSize; i++) { + events.push({ type: 'success', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + await createRawInsightsEvents(workflow, events); + + // ACT + await insightsService.compactInsights(); + + // ASSERT + expect(rawToHourSpy).toHaveBeenCalledTimes(3); + await expect(insightsRawRepository.count()).resolves.toBe(0); + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0); + expect(accumulatedValues).toBe(batchSize); + }); + + test('compaction is running on schedule', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + + // spy on the compactInsights method to check if it's called + insightsService.compactInsights = jest.fn(); + + // ACT + // advance by 1 hour and 1 minute + jest.advanceTimersByTime(1000 * 60 * 60); + + // ASSERT + expect(insightsService.compactInsights).toHaveBeenCalledTimes(1); + }); + }); + + describe('compactHourToDay', () => { + type TestData = { + name: string; + periodStarts: DateTime[]; + batches: number[]; + }; + + test.each([ + { + name: 'compact into 2 rows', + periodStarts: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 23, 59), + DateTime.utc(2000, 1, 2, 1, 0), + ], + batches: [2, 1], + }, + { + name: 'compact into 3 rows', + periodStarts: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 23, 59), + DateTime.utc(2000, 1, 2, 0, 0), + DateTime.utc(2000, 1, 2, 23, 59), + DateTime.utc(2000, 1, 3, 23, 59), + ], + batches: [2, 2, 1], + }, + ])('$name', async ({ periodStarts, batches }) => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + // create before so we can create the raw events in parallel + await createMetadata(workflow); + for (const periodStart of periodStarts) { + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'hour', + periodStart, + }); + } + + // ACT + const compactedRows = await insightsService.compactHourToDay(); + + // ASSERT + expect(compactedRows).toBe(periodStarts.length); + await expect(insightsRawRepository.count()).resolves.toBe(0); + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + expect(allCompacted).toHaveLength(batches.length); + for (const [index, compacted] of allCompacted.entries()) { + expect(compacted.value).toBe(batches[index]); + } + }); + }); +}); diff --git a/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts b/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts index 8781220b93a..38a06eb800b 100644 --- a/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts +++ b/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts @@ -1,3 +1,4 @@ +import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { DateTime } from 'luxon'; import type { IWorkflowBase } from 'n8n-workflow'; @@ -7,8 +8,10 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { InsightsMetadata } from '../../entities/insights-metadata'; import { InsightsRaw } from '../../entities/insights-raw'; +import { InsightsByPeriodRepository } from '../../repositories/insights-by-period.repository'; import { InsightsMetadataRepository } from '../../repositories/insights-metadata.repository'; import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; +import { InsightsByPeriod } from '../insights-by-period'; async function getWorkflowSharing(workflow: IWorkflowBase) { return await Container.get(SharedWorkflowRepository).find({ @@ -16,6 +19,7 @@ async function getWorkflowSharing(workflow: IWorkflowBase) { relations: { project: true }, }); } +export const { type: dbType } = Container.get(GlobalConfig).database; export async function createMetadata(workflow: WorkflowEntity) { const insightsMetadataRepository = Container.get(InsightsMetadataRepository); @@ -62,3 +66,49 @@ export async function createRawInsightsEvent( } return await insightsRawRepository.save(event); } + +export async function createRawInsightsEvents( + workflow: WorkflowEntity, + parametersArray: Array<{ + type: InsightsRaw['type']; + value: number; + timestamp?: DateTime; + }>, +) { + const insightsRawRepository = Container.get(InsightsRawRepository); + const metadata = await createMetadata(workflow); + + const events = parametersArray.map((parameters) => { + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = parameters.type; + event.value = parameters.value; + if (parameters.timestamp) { + event.timestamp = parameters.timestamp.toUTC().toJSDate(); + } + return event; + }); + await insightsRawRepository.save(events); +} + +export async function createCompactedInsightsEvent( + workflow: WorkflowEntity, + parameters: { + type: InsightsByPeriod['type']; + value: number; + periodUnit: InsightsByPeriod['periodUnit']; + periodStart: DateTime; + }, +) { + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + const metadata = await createMetadata(workflow); + + const event = new InsightsByPeriod(); + event.metaId = metadata.metaId; + event.type = parameters.type; + event.value = parameters.value; + event.periodUnit = parameters.periodUnit; + event.periodStart = parameters.periodStart.toUTC().startOf(parameters.periodUnit).toJSDate(); + + return await insightsByPeriodRepository.save(event); +} diff --git a/packages/cli/src/modules/insights/insights.config.ts b/packages/cli/src/modules/insights/insights.config.ts new file mode 100644 index 00000000000..f273f0e11c9 --- /dev/null +++ b/packages/cli/src/modules/insights/insights.config.ts @@ -0,0 +1,18 @@ +import { Config, Env } from '@n8n/config/src/decorators'; + +@Config +export class InsightsConfig { + /** + * The interval in minutes at which the insights data should be compacted. + * Default: 60 + */ + @Env('N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES') + compactionIntervalMinutes: number = 60; + + /** + * The number of raw insights data to compact in a single batch. + * Default: 500 + */ + @Env('N8N_INSIGHTS_COMPACTION_BATCH_SIZE') + compactionBatchSize: number = 500; +} diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 3ccd92b9860..a2d86e262b7 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -1,13 +1,20 @@ -import { Service } from '@n8n/di'; +import { Container, Service } from '@n8n/di'; import type { ExecutionLifecycleHooks } from 'n8n-core'; -import { UnexpectedError } from 'n8n-workflow'; import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow'; +import { UnexpectedError } from 'n8n-workflow'; import { SharedWorkflow } from '@/databases/entities/shared-workflow'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; +import { OnShutdown } from '@/decorators/on-shutdown'; import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata'; import { InsightsRaw } from '@/modules/insights/entities/insights-raw'; +import { InsightsConfig } from './insights.config'; +import { InsightsByPeriodRepository } from './repositories/insights-by-period.repository'; +import { InsightsRawRepository } from './repositories/insights-raw.repository'; + +const config = Container.get(InsightsConfig); + const shouldSkipStatus: Record = { success: false, crashed: false, @@ -35,7 +42,27 @@ const shouldSkipMode: Record = { @Service() export class InsightsService { - constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + private compactInsightsTimer: NodeJS.Timer | undefined; + + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly insightsByPeriodRepository: InsightsByPeriodRepository, + private readonly insightsRawRepository: InsightsRawRepository, + ) { + const intervalMilliseconds = config.compactionIntervalMinutes * 60 * 1000; + this.compactInsightsTimer = setInterval( + async () => await this.compactInsights(), + intervalMilliseconds, + ); + } + + @OnShutdown() + shutdown() { + if (this.compactInsightsTimer !== undefined) { + clearInterval(this.compactInsightsTimer); + this.compactInsightsTimer = undefined; + } + } async workflowExecuteAfterHandler(ctx: ExecutionLifecycleHooks, fullRunData: IRun) { if (shouldSkipStatus[fullRunData.status] || shouldSkipMode[fullRunData.mode]) { @@ -107,4 +134,48 @@ export class InsightsService { } }); } + + async compactInsights() { + let numberOfCompactedRawData: number; + + // Compact raw data to hourly aggregates + do { + numberOfCompactedRawData = await this.compactRawToHour(); + } while (numberOfCompactedRawData > 0); + + let numberOfCompactedHourData: number; + + // Compact hourly data to daily aggregates + do { + numberOfCompactedHourData = await this.compactHourToDay(); + } while (numberOfCompactedHourData > 0); + } + + // Compacts raw data to hourly aggregates + async compactRawToHour() { + // Build the query to gather raw insights data for the batch + const batchQuery = this.insightsRawRepository.getRawInsightsBatchQuery( + config.compactionBatchSize, + ); + + return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({ + sourceBatchQuery: batchQuery.getSql(), + sourceTableName: this.insightsRawRepository.metadata.tableName, + periodUnit: 'hour', + }); + } + + // Compacts hourly data to daily aggregates + async compactHourToDay() { + // get hour data query for batching + const batchQuery = this.insightsByPeriodRepository.getPeriodInsightsBatchQuery( + 'hour', + config.compactionBatchSize, + ); + + return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({ + sourceBatchQuery: batchQuery.getSql(), + periodUnit: 'day', + }); + } } diff --git a/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts index 94bc0572711..e8a911f0564 100644 --- a/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts +++ b/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts @@ -1,11 +1,164 @@ -import { Service } from '@n8n/di'; +import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; import { DataSource, Repository } from '@n8n/typeorm'; +import { sql } from '@/utils/sql'; + import { InsightsByPeriod } from '../entities/insights-by-period'; +import type { PeriodUnits } from '../entities/insights-shared'; +import { PeriodUnitToNumber } from '../entities/insights-shared'; + +const dbType = Container.get(GlobalConfig).database.type; @Service() export class InsightsByPeriodRepository extends Repository { constructor(dataSource: DataSource) { super(InsightsByPeriod, dataSource.manager); } + + private escapeField(fieldName: string) { + return this.manager.connection.driver.escape(fieldName); + } + + private getPeriodFilterExpr(periodUnit: PeriodUnits) { + const daysAgo = periodUnit === 'day' ? 90 : 180; + // Database-specific period start expression to filter out data to compact by days matching the periodUnit + let periodStartExpr = `date('now', '-${daysAgo} days')`; + if (dbType === 'postgresdb') { + periodStartExpr = `CURRENT_DATE - INTERVAL '${daysAgo} day'`; + } else if (dbType === 'mysqldb' || dbType === 'mariadb') { + periodStartExpr = `DATE_SUB(CURRENT_DATE, INTERVAL ${daysAgo} DAY)`; + } + + return periodStartExpr; + } + + private getPeriodStartExpr(periodUnit: PeriodUnits) { + // Database-specific period start expression to truncate timestamp to the periodUnit + // SQLite by default + let periodStartExpr = `strftime('%Y-%m-%d ${periodUnit === 'hour' ? '%H' : '00'}:00:00.000', periodStart)`; + if (dbType === 'mysqldb' || dbType === 'mariadb') { + periodStartExpr = + periodUnit === 'hour' + ? "DATE_FORMAT(periodStart, '%Y-%m-%d %H:00:00')" + : "DATE_FORMAT(periodStart, '%Y-%m-%d 00:00:00')"; + } else if (dbType === 'postgresdb') { + periodStartExpr = `DATE_TRUNC('${periodUnit}', ${this.escapeField('periodStart')})`; + } + + return periodStartExpr; + } + + getPeriodInsightsBatchQuery(periodUnit: PeriodUnits, compactionBatchSize: number) { + // Build the query to gather period insights data for the batch + const batchQuery = this.createQueryBuilder() + .select( + ['id', 'metaId', 'type', 'periodStart', 'value'].map((fieldName) => + this.escapeField(fieldName), + ), + ) + .where(`${this.escapeField('periodUnit')} = ${PeriodUnitToNumber[periodUnit]}`) + .andWhere(`${this.escapeField('periodStart')} < ${this.getPeriodFilterExpr('day')}`) + .orderBy(this.escapeField('periodStart'), 'ASC') + .limit(compactionBatchSize); + return batchQuery; + } + + getAggregationQuery(periodUnit: PeriodUnits) { + // Get the start period expression depending on the period unit and database type + const periodStartExpr = this.getPeriodStartExpr(periodUnit); + + // Function to get the aggregation query + const aggregationQuery = this.manager + .createQueryBuilder() + .select(this.escapeField('metaId')) + .addSelect(this.escapeField('type')) + .addSelect(PeriodUnitToNumber[periodUnit].toString(), 'periodUnit') + .addSelect(periodStartExpr, 'periodStart') + .addSelect(`SUM(${this.escapeField('value')})`, 'value') + .from('rows_to_compact', 'rtc') + .groupBy(this.escapeField('metaId')) + .addGroupBy(this.escapeField('type')) + .addGroupBy(periodStartExpr); + + return aggregationQuery; + } + + async compactSourceDataIntoInsightPeriod({ + sourceBatchQuery, // Query to get batch source data. Must return those fields: 'id', 'metaId', 'type', 'periodStart', 'value' + sourceTableName = this.metadata.tableName, // Repository references for table operations + periodUnit, + }: { + sourceBatchQuery: string; + sourceTableName?: string; + periodUnit: PeriodUnits; + }): Promise { + // Create temp table that only exists in this transaction for rows to compact + const getBatchAndStoreInTemporaryTable = sql` + CREATE TEMPORARY TABLE rows_to_compact AS + ${sourceBatchQuery}; + `; + + const countBatch = sql` + SELECT COUNT(*) ${this.escapeField('rowsInBatch')} FROM rows_to_compact; + `; + + const targetColumnNamesStr = ['metaId', 'type', 'periodUnit', 'periodStart'] + .map((param) => this.escapeField(param)) + .join(', '); + const targetColumnNamesWithValue = `${targetColumnNamesStr}, value`; + + // Function to get the aggregation query + const aggregationQuery = this.getAggregationQuery(periodUnit); + + // Insert or update aggregated data + const insertQueryBase = sql` + INSERT INTO ${this.metadata.tableName} + (${targetColumnNamesWithValue}) + ${aggregationQuery.getSql()} + `; + + // Database-specific duplicate key logic + let deduplicateQuery: string; + if (dbType === 'mysqldb' || dbType === 'mariadb') { + deduplicateQuery = sql` + ON DUPLICATE KEY UPDATE value = value + VALUES(value)`; + } else { + deduplicateQuery = sql` + ON CONFLICT(${targetColumnNamesStr}) + DO UPDATE SET value = ${this.metadata.tableName}.value + excluded.value + RETURNING *`; + } + + const upsertEvents = sql` + ${insertQueryBase} + ${deduplicateQuery} + `; + + // Delete the processed rows + const deleteBatch = sql` + DELETE FROM ${sourceTableName} + WHERE id IN (SELECT id FROM rows_to_compact); + `; + + // Clean up + const dropTemporaryTable = sql` + DROP TABLE rows_to_compact; + `; + + const result = await this.manager.transaction(async (trx) => { + await trx.query(getBatchAndStoreInTemporaryTable); + + await trx.query>(upsertEvents); + + const rowsInBatch = await trx.query<[{ rowsInBatch: number | string }]>(countBatch); + + await trx.query(deleteBatch); + await trx.query(dropTemporaryTable); + + return Number(rowsInBatch[0].rowsInBatch); + }); + + return result; + } } diff --git a/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts b/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts index 9bad708eed8..05eba9ff167 100644 --- a/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts +++ b/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts @@ -8,4 +8,18 @@ export class InsightsRawRepository extends Repository { constructor(dataSource: DataSource) { super(InsightsRaw, dataSource.manager); } + + getRawInsightsBatchQuery(compactionBatchSize: number) { + // Build the query to gather raw insights data for the batch + const batchQuery = this.createQueryBuilder() + .select( + ['id', 'metaId', 'type', 'value'].map((fieldName) => + this.manager.connection.driver.escape(fieldName), + ), + ) + .addSelect('timestamp', 'periodStart') + .orderBy('timestamp', 'ASC') + .limit(compactionBatchSize); + return batchQuery; + } } From 7614dbecd73e8a9e30b62becb0cc725c37bb31a2 Mon Sep 17 00:00:00 2001 From: Dmitrii <117591218+DmitriyM01@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:29:36 +0300 Subject: [PATCH 02/33] fix(n8n Form Trigger Node): Fix docs link (no-changelog) (#14118) --- packages/nodes-base/nodes/Form/Form.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 94a86ca15ae..4a9e9f245e8 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -74,7 +74,7 @@ export const formFieldsProperties: INodeProperties[] = [ '[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]', validateType: 'form-fields', ignoreValidationDuringExecution: true, - hint: 'See docs for field syntax', + hint: 'See docs for field syntax', displayOptions: { show: { defineForm: ['json'], From 59a0ee75dd582badc61aead8faf11082b7c73756 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Mon, 24 Mar 2025 11:32:43 +0100 Subject: [PATCH 03/33] fix(editor): Fix folder creation (no-changelog) (#14125) --- packages/frontend/editor-ui/src/views/WorkflowsView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/views/WorkflowsView.vue index c478ecd78cf..107eafb4131 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/views/WorkflowsView.vue @@ -1218,7 +1218,7 @@ const onCreateWorkflowClick = () => { @sort="onSortUpdated" > { @move="onMove" @update="onUpdate" @open:contextmenu="onOpenContextMenuFromNode" - > - - - + /> - +
@@ -171,6 +175,7 @@ function onActivate() { --configurable-node--icon-size: 30px; --trigger-node--border-radius: 36px; --canvas-node--status-icons-offset: var(--spacing-3xs); + --node-icon-color: var(--color-foreground-dark); position: relative; height: var(--canvas-node--height); diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap index 71b7c37c5c3..cd5075b450b 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap @@ -7,8 +7,24 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
configuration > should render configurable configur style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
configuration > should render configuration node co style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
should render node correctly 1`] = ` style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
trigger > should render trigger node correctly 1`] style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
{ const pinia = createTestingPinia({ @@ -147,6 +148,10 @@ describe('useCanvasMapping', () => { options: { configurable: false, configuration: false, + icon: { + src: '/nodes/test-node/icon.svg', + type: 'file', + }, trigger: true, inputs: { labelSize: 'small', @@ -280,12 +285,19 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); + const rootStore = mockedStore(useRootStore); + rootStore.baseUrl = 'http://test.local/'; + expect(mappedNodes.value[0]?.data?.render).toEqual({ type: CanvasNodeRenderType.Default, options: { configurable: false, configuration: false, trigger: true, + icon: { + src: 'http://test.local/nodes/test-node/icon.svg', + type: 'file', + }, inputs: { labelSize: 'small', }, diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index 4cc9d1d2948..ceff397debe 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -50,6 +50,7 @@ import { MarkerType } from '@vue-flow/core'; import { useNodeHelpers } from './useNodeHelpers'; import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils'; import { useNodeDirtiness } from '@/composables/useNodeDirtiness'; +import { getNodeIconSource } from '../utils/nodeIcon'; export function useCanvasMapping({ nodes, @@ -86,6 +87,8 @@ export function useCanvasMapping({ } function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender { + const nodeType = nodeTypeDescriptionByNodeId.value[node.id]; + const icon = getNodeIconSource(nodeType); return { type: CanvasNodeRenderType.Default, options: { @@ -100,6 +103,7 @@ export function useCanvasMapping({ }, tooltip: nodeTooltipById.value[node.id], dirtiness: dirtinessByName.value[node.name], + icon, }, }; } diff --git a/packages/frontend/editor-ui/src/types/canvas.ts b/packages/frontend/editor-ui/src/types/canvas.ts index c1c05e49666..0fe29d33e60 100644 --- a/packages/frontend/editor-ui/src/types/canvas.ts +++ b/packages/frontend/editor-ui/src/types/canvas.ts @@ -11,6 +11,7 @@ import type { import type { IExecutionResponse, INodeUi } from '@/Interface'; import type { ComputedRef, Ref } from 'vue'; import type { EventBus } from '@n8n/utils/event-bus'; +import type { NodeIconSource } from '../utils/nodeIcon'; export const enum CanvasConnectionMode { Input = 'inputs', @@ -71,6 +72,7 @@ export type CanvasNodeDefaultRender = { }; tooltip?: string; dirtiness?: CanvasNodeDirtinessType; + icon?: NodeIconSource; }>; }; diff --git a/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts b/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts new file mode 100644 index 00000000000..906d8568c5f --- /dev/null +++ b/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts @@ -0,0 +1,140 @@ +import { mock } from 'vitest-mock-extended'; +import { + getNodeIcon, + getNodeIconUrl, + getBadgeIconUrl, + getNodeIconSource, + type IconNodeType, +} from './nodeIcon'; + +vi.mock('../stores/root.store', () => ({ + useRootStore: vi.fn(() => ({ + baseUrl: 'https://example.com/', + })), +})); + +vi.mock('../stores/ui.store', () => ({ + useUIStore: vi.fn(() => ({ + appliedTheme: 'light', + })), +})); + +vi.mock('./nodeTypesUtils', () => ({ + getThemedValue: vi.fn((value, theme) => { + if (typeof value === 'object' && value !== null) { + return value[theme] || value.dark || value.light || null; + } + return value; + }), +})); + +describe('util: Node Icon', () => { + describe('getNodeIcon', () => { + it('should return the icon from nodeType', () => { + expect(getNodeIcon(mock({ icon: 'user', iconUrl: undefined }))).toBe('user'); + }); + + it('should return null if no icon is present', () => { + expect( + getNodeIcon(mock({ icon: undefined, iconUrl: '/test.svg' })), + ).toBeUndefined(); + }); + }); + + describe('getNodeIconUrl', () => { + it('should return the iconUrl from nodeType', () => { + expect( + getNodeIconUrl( + mock({ + iconUrl: { light: 'images/light-icon.svg', dark: 'images/dark-icon.svg' }, + }), + ), + ).toBe('images/light-icon.svg'); + }); + + it('should return null if no iconUrl is present', () => { + expect( + getNodeIconUrl(mock({ icon: 'foo', iconUrl: undefined })), + ).toBeUndefined(); + }); + }); + + describe('getBadgeIconUrl', () => { + it('should return the badgeIconUrl from nodeType', () => { + expect(getBadgeIconUrl({ badgeIconUrl: 'images/badge.svg' })).toBe('images/badge.svg'); + }); + + it('should return null if no badgeIconUrl is present', () => { + expect(getBadgeIconUrl({ badgeIconUrl: undefined })).toBeUndefined(); + }); + }); + + describe('getNodeIconSource', () => { + it('should return undefined if nodeType is null or undefined', () => { + expect(getNodeIconSource(null)).toBeUndefined(); + expect(getNodeIconSource(undefined)).toBeUndefined(); + }); + + it('should create an icon source from iconData.icon if available', () => { + const result = getNodeIconSource( + mock({ iconData: { type: 'icon', icon: 'pencil' } }), + ); + expect(result).toEqual({ + type: 'icon', + name: 'pencil', + color: undefined, + badge: undefined, + }); + }); + + it('should create a file source from iconData.fileBuffer if available', () => { + const result = getNodeIconSource( + mock({ + iconData: { + type: 'file', + icon: undefined, + fileBuffer: 'data://foo', + }, + }), + ); + expect(result).toEqual({ + type: 'file', + src: 'data://foo', + badge: undefined, + }); + }); + + it('should create a file source from iconUrl if available', () => { + const result = getNodeIconSource(mock({ iconUrl: 'images/node-icon.svg' })); + expect(result).toEqual({ + type: 'file', + src: 'https://example.com/images/node-icon.svg', + badge: undefined, + }); + }); + + it('should create an icon source from icon if available', () => { + const result = getNodeIconSource( + mock({ + icon: 'icon:user', + iconColor: 'blue', + iconData: undefined, + iconUrl: undefined, + }), + ); + expect(result).toEqual({ + type: 'icon', + name: 'user', + color: 'var(--color-node-icon-blue)', + }); + }); + + it('should include badge if available', () => { + const result = getNodeIconSource(mock({ badgeIconUrl: 'images/badge.svg' })); + expect(result?.badge).toEqual({ + type: 'file', + src: 'https://example.com/images/badge.svg', + }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/utils/nodeIcon.ts b/packages/frontend/editor-ui/src/utils/nodeIcon.ts new file mode 100644 index 00000000000..1f79f1fd4e3 --- /dev/null +++ b/packages/frontend/editor-ui/src/utils/nodeIcon.ts @@ -0,0 +1,109 @@ +import { type INodeTypeDescription } from 'n8n-workflow'; +import type { IVersionNode } from '../Interface'; +import { useRootStore } from '../stores/root.store'; +import { useUIStore } from '../stores/ui.store'; +import { getThemedValue } from './nodeTypesUtils'; + +type NodeIconSourceIcon = { type: 'icon'; name: string; color?: string }; +type NodeIconSourceFile = { + type: 'file'; + src: string; +}; + +type BaseNodeIconSource = NodeIconSourceIcon | NodeIconSourceFile; +export type NodeIconSource = BaseNodeIconSource & { badge?: BaseNodeIconSource }; + +export type NodeIconType = 'file' | 'icon' | 'unknown'; + +type IconNodeTypeDescription = Pick< + INodeTypeDescription, + 'icon' | 'iconUrl' | 'iconColor' | 'defaults' | 'badgeIconUrl' +>; +type IconVersionNode = Pick; +export type IconNodeType = IconNodeTypeDescription | IconVersionNode; + +export const getNodeIcon = (nodeType: IconNodeType): string | null => { + return getThemedValue(nodeType.icon, useUIStore().appliedTheme); +}; + +export const getNodeIconUrl = (nodeType: IconNodeType): string | null => { + return getThemedValue(nodeType.iconUrl, useUIStore().appliedTheme); +}; + +export const getBadgeIconUrl = ( + nodeType: Pick, +): string | null => { + return getThemedValue(nodeType.badgeIconUrl, useUIStore().appliedTheme); +}; + +function getNodeIconColor(nodeType: IconNodeType) { + if ('iconColor' in nodeType && nodeType.iconColor) { + return `var(--color-node-icon-${nodeType.iconColor})`; + } + return nodeType?.defaults?.color?.toString(); +} + +function prefixBaseUrl(url: string) { + return useRootStore().baseUrl + url; +} + +export function getNodeIconSource(nodeType?: IconNodeType | null): NodeIconSource | undefined { + if (!nodeType) return undefined; + const createFileIconSource = (src: string): NodeIconSource => ({ + type: 'file', + src, + badge: getNodeBadgeIconSource(nodeType), + }); + const createNamedIconSource = (name: string): NodeIconSource => ({ + type: 'icon', + name, + color: getNodeIconColor(nodeType), + badge: getNodeBadgeIconSource(nodeType), + }); + + // If node type has icon data, use it + if ('iconData' in nodeType && nodeType.iconData) { + if (nodeType.iconData.icon) { + return createNamedIconSource(nodeType.iconData.icon); + } + + if (nodeType.iconData.fileBuffer) { + return createFileIconSource(nodeType.iconData.fileBuffer); + } + } + + const iconUrl = getNodeIconUrl(nodeType); + if (iconUrl) { + return createFileIconSource(prefixBaseUrl(iconUrl)); + } + + // Otherwise, extract it from icon prop + if (nodeType.icon) { + const icon = getNodeIcon(nodeType); + + if (icon) { + const [type, iconName] = icon.split(':'); + if (type === 'file') { + return undefined; + } + + return createNamedIconSource(iconName); + } + } + + return undefined; +} + +function getNodeBadgeIconSource(nodeType: IconNodeType): BaseNodeIconSource | undefined { + if (nodeType && 'badgeIconUrl' in nodeType && nodeType.badgeIconUrl) { + const badgeUrl = getBadgeIconUrl(nodeType); + + if (!badgeUrl) return undefined; + return { + type: 'file', + src: prefixBaseUrl(badgeUrl), + }; + } + + return undefined; +} diff --git a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts index 3b732dcda62..8ced5b02423 100644 --- a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts +++ b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts @@ -3,9 +3,7 @@ import type { INodeUi, INodeUpdatePropertiesInformation, ITemplatesNode, - IVersionNode, NodeAuthenticationOption, - SimplifiedNodeType, } from '@/Interface'; import { CORE_NODES_CATEGORY, @@ -502,33 +500,3 @@ export const getThemedValue = ( return value[theme]; }; - -export const getNodeIcon = ( - nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode, - theme: AppliedThemeOption = 'light', -): string | null => { - return getThemedValue(nodeType.icon, theme); -}; - -export const getNodeIconUrl = ( - nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode, - theme: AppliedThemeOption = 'light', -): string | null => { - return getThemedValue(nodeType.iconUrl, theme); -}; - -export const getBadgeIconUrl = ( - nodeType: INodeTypeDescription | SimplifiedNodeType, - theme: AppliedThemeOption = 'light', -): string | null => { - return getThemedValue(nodeType.badgeIconUrl, theme); -}; - -export const getNodeIconColor = ( - nodeType?: INodeTypeDescription | SimplifiedNodeType | IVersionNode | null, -) => { - if (nodeType && 'iconColor' in nodeType && nodeType.iconColor) { - return `var(--color-node-icon-${nodeType.iconColor})`; - } - return nodeType?.defaults?.color?.toString(); -}; From ef66518c92d976ccb575818d38da02aadad66fcc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:47:49 +0100 Subject: [PATCH 12/33] :rocket: Release 1.85.0 (#14135) Co-authored-by: CharlieKolb <13814565+CharlieKolb@users.noreply.github.com> --- CHANGELOG.md | 44 +++++++++++++++++++ package.json | 2 +- packages/@n8n/api-types/package.json | 2 +- packages/@n8n/config/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/@n8n/permissions/package.json | 2 +- packages/@n8n/task-runner/package.json | 2 +- packages/@n8n/utils/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/frontend/@n8n/chat/package.json | 2 +- .../frontend/@n8n/design-system/package.json | 2 +- packages/frontend/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 16 files changed, 59 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc41d7386f..fb5f8b3eb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +# [1.85.0](https://github.com/n8n-io/n8n/compare/n8n@1.84.0...n8n@1.85.0) (2025-03-24) + + +### Bug Fixes + +* Allow saved credenitals types of up to 64 characters instead of 32 ([#13985](https://github.com/n8n-io/n8n/issues/13985)) ([bc15bb1](https://github.com/n8n-io/n8n/commit/bc15bb18d9f33abdeed24e26826e7f3308d3eef2)) +* Allow username to be set in Redis chat memory ([#13926](https://github.com/n8n-io/n8n/issues/13926)) ([b2e359a](https://github.com/n8n-io/n8n/commit/b2e359ac1c2dfdf79f8d50fe83998eda5fc34dd2)) +* **core:** Allow running webhook servers in multi-main mode ([#13989](https://github.com/n8n-io/n8n/issues/13989)) ([e0fd505](https://github.com/n8n-io/n8n/commit/e0fd50554d48c873c8f77169d1a17438391dd973)) +* **core:** Bring back the missing GMT and UTC timezone for workflow settings ([#13999](https://github.com/n8n-io/n8n/issues/13999)) ([bda0688](https://github.com/n8n-io/n8n/commit/bda068880ea7a44718e01a156e97f09c9ec2bc46)) +* **core:** Do not use `url.includes` to check for domain names ([#13802](https://github.com/n8n-io/n8n/issues/13802)) ([d3bc80c](https://github.com/n8n-io/n8n/commit/d3bc80c22bbbf0ae39c88a6f085d5f80aa8a0e82)) +* **core:** Don't fail partial execution when an unrelated node is dirty ([#13925](https://github.com/n8n-io/n8n/issues/13925)) ([918cc51](https://github.com/n8n-io/n8n/commit/918cc51abc79bbcfb6a333d5ecafa07a9e986b6f)) +* **core:** Ensure frontend sentry releases also follow semver ([#14019](https://github.com/n8n-io/n8n/issues/14019)) ([401ed2c](https://github.com/n8n-io/n8n/commit/401ed2ce1194ad7ff238debff418f0db77eb06e6)) +* **editor:** Add "time saved per execution" workflow setting ([#13369](https://github.com/n8n-io/n8n/issues/13369)) ([6992c36](https://github.com/n8n-io/n8n/commit/6992c36ebb3aa608ce31396f9b7ed0aa10c80299)) +* **editor:** Add smart decimals directive ([#14054](https://github.com/n8n-io/n8n/issues/14054)) ([1a26fc2](https://github.com/n8n-io/n8n/commit/1a26fc2762dee366d2ce7ccf24e173cdc761c70c)) +* **editor:** Fix routing between workflow editing and new workflow pages ([#14031](https://github.com/n8n-io/n8n/issues/14031)) ([6817abe](https://github.com/n8n-io/n8n/commit/6817abe47facd7ff0e42a66599827d42c4df757c)) + + +### Features + +* Add appendN8nAttribution option to sendAndWait operation ([#13697](https://github.com/n8n-io/n8n/issues/13697)) ([d6d5a66](https://github.com/n8n-io/n8n/commit/d6d5a66f5dc28d926755ca8153f91c7be0742cf5)) +* Add xAiGrok Chat Model node and credentials ([#13670](https://github.com/n8n-io/n8n/issues/13670)) ([cc502fb](https://github.com/n8n-io/n8n/commit/cc502fb8c34b65d569b4abe4603cc8ef1eadc7a7)) +* Allow custom scopes for Entra credential ([#13796](https://github.com/n8n-io/n8n/issues/13796)) ([7e10361](https://github.com/n8n-io/n8n/commit/7e1036187ff7bd5be990f191a3ac8ef002e7812a)) +* **API:** Fix generation strategy for mysql/mariadb ([#14028](https://github.com/n8n-io/n8n/issues/14028)) ([24d8eac](https://github.com/n8n-io/n8n/commit/24d8eac85d8ce95671aabf8500139b3ef3e19a56)) +* **API:** Implement compaction logic for insights ([#14062](https://github.com/n8n-io/n8n/issues/14062)) ([d8433d2](https://github.com/n8n-io/n8n/commit/d8433d289543c40854e59b0384be356a3d7b947d)) +* Cat 720 improve pre merge ci ([#14116](https://github.com/n8n-io/n8n/issues/14116)) ([743b63e](https://github.com/n8n-io/n8n/commit/743b63e97a9a96dfaf35f138a79eddaad9bb2dbb)) +* **core:** Add folder synchronization to environments feature ([#14005](https://github.com/n8n-io/n8n/issues/14005)) ([198f17d](https://github.com/n8n-io/n8n/commit/198f17dbcf0b21e579f9a68466494662257dbe44)) +* **core:** Add tool to uninstall a community node ([#14026](https://github.com/n8n-io/n8n/issues/14026)) ([e0f9506](https://github.com/n8n-io/n8n/commit/e0f9506912aa6a129df332185063291f0627f9ca)) +* **core:** Allow community nodes to be used as tools ([#14042](https://github.com/n8n-io/n8n/issues/14042)) ([9d698ed](https://github.com/n8n-io/n8n/commit/9d698edcebc8cdbf9fefc3bf89a13f9daa32f40b)) +* **core:** Allow customizing auth cookie samesite attribute and CSP headers ([#13855](https://github.com/n8n-io/n8n/issues/13855)) ([17fc5c1](https://github.com/n8n-io/n8n/commit/17fc5c148b99b8f346abf2142a1d2bee567b2621)) +* **core:** Enable folders feature via license server ([#13942](https://github.com/n8n-io/n8n/issues/13942)) ([fa7e7ac](https://github.com/n8n-io/n8n/commit/fa7e7ac2e7b38418619ebe1f3839d47c491419d2)) +* **core:** Implement API to retrieve summary metrics ([#13927](https://github.com/n8n-io/n8n/issues/13927)) ([b616ceb](https://github.com/n8n-io/n8n/commit/b616ceb08b712ecd350114acc48a9a0f35843c0a)) +* **core:** Support importing a singular workflow object ([#14041](https://github.com/n8n-io/n8n/issues/14041)) ([91b2796](https://github.com/n8n-io/n8n/commit/91b27964d80309ce493200289b31a83ef6051b4d)) +* **core:** Update endpoint to update a workflow, to support updating the workflow parent folder (no-chagelog) ([#13906](https://github.com/n8n-io/n8n/issues/13906)) ([3a5cc4a](https://github.com/n8n-io/n8n/commit/3a5cc4ae957ea5f370472f08d2af4ac29c3b21b2)) +* **editor:** Add variables and context section to schema view ([#13875](https://github.com/n8n-io/n8n/issues/13875)) ([c06ce76](https://github.com/n8n-io/n8n/commit/c06ce765f11dcde4731d3739e1aa5f27351c3cc2)) +* **editor:** Always show collapsed panel at the bottom of canvas ([#13715](https://github.com/n8n-io/n8n/issues/13715)) ([2e9d3ad](https://github.com/n8n-io/n8n/commit/2e9d3ad3e14da7aa2f3b3b9577858791e9128908)) +* **editor:** Insights summary banner ([#13424](https://github.com/n8n-io/n8n/issues/13424)) ([df474f3](https://github.com/n8n-io/n8n/commit/df474f3ccbc629a8e308359e6a4973cc00b86e17)) +* **Extract from File Node:** Add relax_quote option ([#13607](https://github.com/n8n-io/n8n/issues/13607)) ([830d2c5](https://github.com/n8n-io/n8n/commit/830d2c5df53c5436f89868dfe23cf55c41585a46)) +* **n8n Form Trigger Node:** Respond with File ([#13507](https://github.com/n8n-io/n8n/issues/13507)) ([8f46371](https://github.com/n8n-io/n8n/commit/8f46371d77262aa0a924e1c58cf9691327e0f193)) +* **Salesforce Node:** Add support for PKCE ([#14082](https://github.com/n8n-io/n8n/issues/14082)) ([defeb2e](https://github.com/n8n-io/n8n/commit/defeb2e817dbc559844124f20e6bebf7717d878a)) +* **SeaTable Node:** Update node with new options ([#11431](https://github.com/n8n-io/n8n/issues/11431)) ([d0fdb11](https://github.com/n8n-io/n8n/commit/d0fdb11499de2e5fb1602b7cc86f2b24543ce50f)) +* **Simple Vector Store Node:** Implement store cleaning based on age/used memory ([#13986](https://github.com/n8n-io/n8n/issues/13986)) ([e06c552](https://github.com/n8n-io/n8n/commit/e06c552a6a0471ec60862247f6a597b8ab5f9cd3)) + + + # [1.84.0](https://github.com/n8n-io/n8n/compare/n8n@1.83.0...n8n@1.84.0) (2025-03-17) diff --git a/package.json b/package.json index 9908102a61e..f9fdc2828ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.84.0", + "version": "1.85.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 27d63519b60..bf91088a52a 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.19.0", + "version": "0.20.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 48eac78ba61..87968268cba 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.32.0", + "version": "1.33.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 76be1524899..00a8e9862b9 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.84.0", + "version": "1.85.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index d96e13234cd..021bfc00930 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.20.0", + "version": "0.21.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 87ab8c0015a..d521acfb799 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.21.0", + "version": "1.22.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index a95ae83ac83..aee911b2058 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/utils", "type": "module", - "version": "1.4.0", + "version": "1.5.0", "files": [ "dist" ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 12fa4323532..88d4c2f07e3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.84.0", + "version": "1.85.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index b973ea296fb..323f0e2ed8b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.83.0", + "version": "1.84.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/frontend/@n8n/chat/package.json b/packages/frontend/@n8n/chat/package.json index 0825aff00e0..2330ab58ef8 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.36.0", + "version": "0.37.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 42ac372a0be..aabcc2b90bf 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/design-system", - "version": "1.72.0", + "version": "1.73.0", "main": "src/index.ts", "import": "src/index.ts", "scripts": { diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 785c4df536a..7399676bd2c 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.84.0", + "version": "1.85.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 5bd699538ac..ce069a6ff3e 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.83.0", + "version": "1.84.0", "description": "CLI to simplify n8n credentials/node development", "main": "dist/src/index", "types": "dist/src/index.d.ts", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 618827c4f15..715f4afaa8e 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.83.0", + "version": "1.84.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 7eec64ef4c4..9ed91665f24 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.82.0", + "version": "1.83.0", "description": "Workflow base code of n8n", "main": "dist/index.js", "module": "src/index.ts", From fdcca1d0ed7f7c32aa6c40f4751f554826064619 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Mon, 24 Mar 2025 15:52:37 +0100 Subject: [PATCH 13/33] fix(editor): Adjust URL on lost change warning Cancel or failed save (#13683) --- .../composables/modals/save-changes-modal.ts | 10 ++++ cypress/composables/workflowsPage.ts | 4 ++ cypress/e2e/44-routing.cy.ts | 55 +++++++++++++++---- .../src/composables/useWorkflowHelpers.ts | 32 ++++++++--- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/cypress/composables/modals/save-changes-modal.ts b/cypress/composables/modals/save-changes-modal.ts index d44b09bd460..e2a629a63ec 100644 --- a/cypress/composables/modals/save-changes-modal.ts +++ b/cypress/composables/modals/save-changes-modal.ts @@ -1,3 +1,13 @@ export function getSaveChangesModal() { return cy.get('.el-overlay').contains('Save changes before leaving?'); } + +// this is the button next to 'Save Changes' +export function getCancelSaveChangesButton() { + return cy.get('.btn--cancel'); +} + +// This is the top right 'x' +export function getCloseSaveChangesButton() { + return cy.get('.el-message-box__headerbtn'); +} diff --git a/cypress/composables/workflowsPage.ts b/cypress/composables/workflowsPage.ts index c7bcf398886..cbab641de21 100644 --- a/cypress/composables/workflowsPage.ts +++ b/cypress/composables/workflowsPage.ts @@ -6,6 +6,10 @@ export function getWorkflowsPageUrl() { return '/home/workflows'; } +export const getCreateWorkflowButton = () => cy.getByTestId('add-resource-workflow'); + +export const getNewWorkflowCardButton = () => cy.getByTestId('new-workflow-card'); + /** * Actions */ diff --git a/cypress/e2e/44-routing.cy.ts b/cypress/e2e/44-routing.cy.ts index 1d3a8746a93..c3129c4e9d5 100644 --- a/cypress/e2e/44-routing.cy.ts +++ b/cypress/e2e/44-routing.cy.ts @@ -1,26 +1,59 @@ -import { getSaveChangesModal } from '../composables/modals/save-changes-modal'; +import { + getCancelSaveChangesButton, + getCloseSaveChangesButton, + getSaveChangesModal, +} from '../composables/modals/save-changes-modal'; +import { getHomeButton } from '../composables/projects'; +import { addNodeToCanvas } from '../composables/workflow'; +import { + getCreateWorkflowButton, + getNewWorkflowCardButton, + getWorkflowsPageUrl, + visitWorkflowsPage, +} from '../composables/workflowsPage'; import { EDIT_FIELDS_SET_NODE_NAME } from '../constants'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; - -const WorkflowsPage = new WorkflowsPageClass(); -const WorkflowPage = new WorkflowPageClass(); describe('Workflows', () => { beforeEach(() => { - cy.visit(WorkflowsPage.url); + visitWorkflowsPage(); }); it('should ask to save unsaved changes before leaving route', () => { - WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible'); - WorkflowsPage.getters.newWorkflowButtonCard().click(); + getNewWorkflowCardButton().should('be.visible'); + getNewWorkflowCardButton().click(); cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow'); - WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); - cy.getByTestId('project-home-menu-item').click(); + getHomeButton().click(); + + // We expect to still be on the workflow route here + cy.url().should('include', '/workflow/'); getSaveChangesModal().should('be.visible'); + getCancelSaveChangesButton().click(); + + // Only now do we switch + cy.url().should('include', getWorkflowsPageUrl()); + }); + + it('should correct route after cancelling saveChangesModal', () => { + getCreateWorkflowButton().click(); + + cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow'); + addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + + // Here we go back via browser rather than the home button + // As this already updates the route + cy.go(-1); + + cy.url().should('include', getWorkflowsPageUrl()); + + getSaveChangesModal().should('be.visible'); + getCloseSaveChangesButton().click(); + + // Confirm the url is back to the workflow + cy.url().should('include', '/workflow/'); }); }); diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts index cb1d8871b3a..5652851931a 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts @@ -1,6 +1,7 @@ import { HTTP_REQUEST_NODE_TYPE, MODAL_CANCEL, + MODAL_CLOSE, MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_FILLED_AT_EXECUTION_TIME, @@ -827,6 +828,12 @@ export function useWorkflowHelpers(options: { router: ReturnType Date: Mon, 24 Mar 2025 15:08:25 +0000 Subject: [PATCH 14/33] fix(Baserow Node): Fix issue where database selection was returning other types (#14115) --- .../nodes-base/nodes/Baserow/Baserow.node.ts | 4 +- .../__tests__/GenericFunctions.test.ts | 141 ++++++++ .../__tests__/workflow/apiResponses.ts | 85 +++++ .../Baserow/__tests__/workflow/workflow.json | 328 ++++++++++++++++++ .../__tests__/workflow/workflow.test.ts | 63 ++++ packages/nodes-base/nodes/Baserow/types.ts | 1 + .../test/nodes/FakeCredentialsMap.ts | 5 + 7 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/nodes/Baserow/__tests__/GenericFunctions.test.ts create mode 100644 packages/nodes-base/nodes/Baserow/__tests__/workflow/apiResponses.ts create mode 100644 packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.json create mode 100644 packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.test.ts diff --git a/packages/nodes-base/nodes/Baserow/Baserow.node.ts b/packages/nodes-base/nodes/Baserow/Baserow.node.ts index 27cd84e3f80..19398d43510 100644 --- a/packages/nodes-base/nodes/Baserow/Baserow.node.ts +++ b/packages/nodes-base/nodes/Baserow/Baserow.node.ts @@ -120,7 +120,9 @@ export class Baserow implements INodeType { endpoint, jwtToken, )) as LoadedResource[]; - return toOptions(databases); + // Baserow has different types of applications, we only want the databases + // https://api.baserow.io/api/redoc/#tag/Applications/operation/list_all_applications + return toOptions(databases.filter((database) => database.type === 'database')); }, async getTableIds(this: ILoadOptionsFunctions) { diff --git a/packages/nodes-base/nodes/Baserow/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Baserow/__tests__/GenericFunctions.test.ts new file mode 100644 index 00000000000..6a6170407f1 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/__tests__/GenericFunctions.test.ts @@ -0,0 +1,141 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ +import { NodeApiError } from 'n8n-workflow'; + +import { + baserowApiRequest, + baserowApiRequestAllItems, + getJwtToken, + getFieldNamesAndIds, + toOptions, + TableFieldMapper, +} from '../GenericFunctions'; + +describe('Baserow > GenericFunctions', () => { + const mockExecuteFunctions: any = { + helpers: { + request: jest.fn(), + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'nathan@n8n.io', + password: 'this-is-a-fake-password', + host: 'https://api.baserow.io', + }), + getNodeParameter: jest.fn(), + getNode: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('baserowApiRequest', () => { + it('should return data on success', async () => { + mockExecuteFunctions.helpers.request.mockResolvedValue({ success: true }); + const result = await baserowApiRequest.call( + mockExecuteFunctions, + 'GET', + '/endpoint', + 'testJwt', + ); + expect(result).toEqual({ success: true }); + expect(mockExecuteFunctions.helpers.request).toHaveBeenCalled(); + }); + + it('should throw NodeApiError on failure', async () => { + mockExecuteFunctions.helpers.request.mockRejectedValue({ error: 'fail' }); + await expect( + baserowApiRequest.call(mockExecuteFunctions, 'GET', '/endpoint', 'testJwt'), + ).rejects.toThrow(NodeApiError); + }); + }); + + describe('baserowApiRequestAllItems', () => { + it('should accumulate all pages', async () => { + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce(true) // returnAll + .mockReturnValue(1000); // limit + mockExecuteFunctions.helpers.request + .mockResolvedValueOnce({ results: [{ data: 1 }], next: 'page2' }) + .mockResolvedValueOnce({ results: [{ data: 2 }], next: null }); + + const result = await baserowApiRequestAllItems.call( + mockExecuteFunctions, + 'GET', + '/endpoint', + 'testJwt', + {}, + {}, + ); + + expect(result).toEqual([{ data: 1 }, { data: 2 }]); + }); + }); + + describe('getJwtToken', () => { + it('should return a token', async () => { + mockExecuteFunctions.helpers.request.mockResolvedValue({ token: 'mockToken' }); + const result = await getJwtToken.call(mockExecuteFunctions, { + username: 'nathan@n8n.io', + password: 'this-is-a-fake-password', + host: 'https://api.baserow.io', + }); + expect(result).toBe('mockToken'); + }); + + it('should throw NodeApiError if request fails', async () => { + mockExecuteFunctions.helpers.request.mockRejectedValue({ error: 'fail' }); + await expect( + getJwtToken.call(mockExecuteFunctions, { + username: 'nathan@n8n.io', + password: 'this-is-a-fake-password', + host: 'https://api.baserow.io', + }), + ).rejects.toThrow(NodeApiError); + }); + }); + + describe('getFieldNamesAndIds', () => { + it('should return field names and ids', async () => { + mockExecuteFunctions.helpers.request.mockResolvedValue([ + { id: 1, name: 'field1' }, + { id: 2, name: 'field2' }, + ]); + const result = await getFieldNamesAndIds.call(mockExecuteFunctions, '1', 'testJwt'); + expect(result).toEqual({ + names: ['field1', 'field2'], + ids: ['field_1', 'field_2'], + }); + }); + }); + + describe('toOptions', () => { + it('should map items to options', () => { + const result = toOptions([ + { id: 1, name: 'field1' }, + { id: 2, name: 'field2' }, + ]); + expect(result).toEqual([ + { name: 'field1', value: 1 }, + { name: 'field2', value: 2 }, + ]); + }); + }); + + describe('TableFieldMapper', () => { + it('should create name-to-id and id-to-name mappings', () => { + const mapper = new TableFieldMapper(); + mapper.createMappings([ + { id: 1, name: 'field1' }, + { id: 2, name: 'field2' }, + ]); + expect(mapper.nameToIdMapping).toEqual({ + field1: 'field_1', + field2: 'field_2', + }); + expect(mapper.idToNameMapping).toEqual({ + field_1: 'field1', + field_2: 'field2', + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/__tests__/workflow/apiResponses.ts b/packages/nodes-base/nodes/Baserow/__tests__/workflow/apiResponses.ts new file mode 100644 index 00000000000..61ea2d22a91 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/__tests__/workflow/apiResponses.ts @@ -0,0 +1,85 @@ +export const fieldsResponse = [ + { + id: 3799030, + table_id: 482710, + name: 'Name', + order: 0, + type: 'text', + primary: true, + read_only: false, + immutable_type: false, + immutable_properties: false, + description: null, + text_default: '', + }, + { + id: 3799031, + table_id: 482710, + name: 'Notes', + order: 1, + type: 'long_text', + primary: false, + read_only: false, + immutable_type: false, + immutable_properties: false, + description: null, + long_text_enable_rich_text: false, + }, + { + id: 3799032, + table_id: 482710, + name: 'Active', + order: 2, + type: 'boolean', + primary: false, + read_only: false, + immutable_type: false, + immutable_properties: false, + description: null, + }, +]; +export const getResponse = { + id: 1, + order: '1.00000000000000000000', + field_3799030: 'Foo', + field_3799031: 'bar', + field_3799032: false, +}; + +export const getAllResponse = { + count: 2, + next: null, + previous: null, + results: [ + { + id: 1, + order: '1.00000000000000000000', + field_3799030: 'Foo', + field_3799031: 'bar', + field_3799032: false, + }, + { + id: 2, + order: '2.00000000000000000000', + field_3799030: 'Bar', + field_3799031: 'foo', + field_3799032: true, + }, + ], +}; + +export const createResponse = { + id: 3, + order: '3.00000000000000000000', + field_3799030: 'Nathan', + field_3799031: 'testing', + field_3799032: false, +}; + +export const updateResponse = { + id: 3, + order: '3.00000000000000000000', + field_3799030: 'Nathan', + field_3799031: 'testing', + field_3799032: true, +}; diff --git a/packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.json b/packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.json new file mode 100644 index 00000000000..2414c4b6905 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.json @@ -0,0 +1,328 @@ +{ + "name": "Baserow Test Workflow", + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-20, 400], + "id": "fccdbab1-aa37-4606-8744-520e19a90a01", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "get", + "databaseId": 199364, + "tableId": 482710, + "rowId": "1" + }, + "type": "n8n-nodes-base.baserow", + "typeVersion": 1, + "position": [200, 0], + "id": "56b90399-9400-4fff-84b5-75430102119d", + "name": "Baserow > Get", + "credentials": { + "baserowApi": { + "id": "SWSFqWDWdnC74WMJ", + "name": "NodeQA" + } + } + }, + { + "parameters": { + "databaseId": 199364, + "tableId": 482710, + "limit": 2, + "additionalOptions": {} + }, + "type": "n8n-nodes-base.baserow", + "typeVersion": 1, + "position": [200, 200], + "id": "8a183257-3297-4ec9-bcae-aefb1f563355", + "name": "Baserow > Get Many", + "credentials": { + "baserowApi": { + "id": "SWSFqWDWdnC74WMJ", + "name": "NodeQA" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [420, 0], + "id": "5afa3206-a796-41dc-a2c5-54b5b9f2fbf4", + "name": "GetResponse" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [420, 200], + "id": "d61f9e61-856a-4590-aad1-d7ef88e7185b", + "name": "GetMany Response" + }, + { + "parameters": { + "operation": "create", + "databaseId": 199364, + "tableId": 482710, + "fieldsUi": { + "fieldValues": [ + { + "fieldId": 3799030, + "fieldValue": "Nathan" + }, + { + "fieldId": 3799031, + "fieldValue": "testing" + } + ] + } + }, + "type": "n8n-nodes-base.baserow", + "typeVersion": 1, + "position": [200, 400], + "id": "e3c5b6c4-b4b3-40ac-8d12-2a240c174e81", + "name": "Baserow > Create", + "credentials": { + "baserowApi": { + "id": "SWSFqWDWdnC74WMJ", + "name": "NodeQA" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [420, 400], + "id": "bb7648a4-1d81-421f-927d-0942325db4c9", + "name": "Create Response" + }, + { + "parameters": { + "operation": "update", + "databaseId": 199364, + "tableId": 482710, + "rowId": "3", + "fieldsUi": { + "fieldValues": [ + { + "fieldId": 3799032, + "fieldValue": "true" + } + ] + } + }, + "type": "n8n-nodes-base.baserow", + "typeVersion": 1, + "position": [200, 600], + "id": "557a163a-0d60-4dbf-b4e0-342533b56ad5", + "name": "Baserow > Update", + "credentials": { + "baserowApi": { + "id": "SWSFqWDWdnC74WMJ", + "name": "NodeQA" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [420, 600], + "id": "34992c77-25d1-4af4-a667-ca452049908d", + "name": "Update Response" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [420, 800], + "id": "76e75c2a-2c6e-4307-ad67-fbc3d442ea53", + "name": "Delete Response" + }, + { + "parameters": { + "operation": "delete", + "databaseId": 199364, + "tableId": 482710, + "rowId": "3" + }, + "type": "n8n-nodes-base.baserow", + "typeVersion": 1, + "position": [200, 800], + "id": "f1c84a01-f514-49c2-a54e-548b15dc91fd", + "name": "Baserow > Delete", + "credentials": { + "baserowApi": { + "id": "SWSFqWDWdnC74WMJ", + "name": "NodeQA" + } + } + } + ], + "pinData": { + "GetResponse": [ + { + "json": { + "id": 1, + "order": "1.00000000000000000000", + "Name": "Foo", + "Notes": "bar", + "Active": false + } + } + ], + "GetMany Response": [ + { + "json": { + "id": 1, + "order": "1.00000000000000000000", + "Name": "Foo", + "Notes": "bar", + "Active": false + } + }, + { + "json": { + "id": 2, + "order": "2.00000000000000000000", + "Name": "Bar", + "Notes": "foo", + "Active": true + } + } + ], + "Create Response": [ + { + "json": { + "id": 3, + "order": "3.00000000000000000000", + "Name": "Nathan", + "Notes": "testing", + "Active": false + } + } + ], + "Update Response": [ + { + "json": { + "id": 3, + "order": "3.00000000000000000000", + "Name": "Nathan", + "Notes": "testing", + "Active": true + } + } + ], + "Delete Response": [ + { + "json": { + "success": true + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Baserow > Get", + "type": "main", + "index": 0 + }, + { + "node": "Baserow > Get Many", + "type": "main", + "index": 0 + }, + { + "node": "Baserow > Create", + "type": "main", + "index": 0 + }, + { + "node": "Baserow > Update", + "type": "main", + "index": 0 + }, + { + "node": "Baserow > Delete", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baserow > Get": { + "main": [ + [ + { + "node": "GetResponse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baserow > Get Many": { + "main": [ + [ + { + "node": "GetMany Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baserow > Create": { + "main": [ + [ + { + "node": "Create Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baserow > Update": { + "main": [ + [ + { + "node": "Update Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baserow > Delete": { + "main": [ + [ + { + "node": "Delete Response", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "72dbd4c1-80b9-4a22-a298-7bcd577e2f0c", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "0fa937d34dcabeff4bd6480d3b42cc95edf3bc20e6810819086ef1ce2623639d" + }, + "id": "2IrLMcqSSFfSyj76", + "tags": [] +} diff --git a/packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.test.ts b/packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.test.ts new file mode 100644 index 00000000000..7d6863153f1 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/__tests__/workflow/workflow.test.ts @@ -0,0 +1,63 @@ +import nock from 'nock'; + +import { + createResponse, + fieldsResponse, + getAllResponse, + getResponse, + updateResponse, +} from './apiResponses'; +import { + setup, + equalityTest, + workflowToTests, + getWorkflowFilenames, +} from '../../../../test/nodes/Helpers'; + +describe('Baserow > Workflows', () => { + describe('Run workflow', () => { + const workflows = getWorkflowFilenames(__dirname); + const tests = workflowToTests(workflows); + + beforeAll(() => { + const mock = nock('https://api.baserow.io'); + // Baserow > Get Token + mock + .persist() + .post('/api/user/token-auth/', { username: 'nathan@n8n.io', password: 'fake-password' }) + .reply(200, { + token: 'fake-jwt-token', + }); + // Baserow > Get Fields + mock.get('/api/database/fields/table/482710/').reply(200, fieldsResponse); + // Baserow > Get Row + mock.get('/api/database/rows/table/482710/1/').reply(200, getResponse); + // Baserow > Get all rows + mock + .get('/api/database/rows/table/482710/') + .query({ page: 1, size: 100 }) + .reply(200, getAllResponse); + // Baserow > Create Row + mock + .post('/api/database/rows/table/482710/', { + field_3799030: 'Nathan', + field_3799031: 'testing', + }) + .reply(200, createResponse); + // Baserow > Update Row + mock + .patch('/api/database/rows/table/482710/3/', { + field_3799032: 'true', + }) + .reply(200, updateResponse); + // Baserow > Delete Row + mock.delete('/api/database/rows/table/482710/3/').reply(200, {}); + }); + + const nodeTypes = setup(tests); + + for (const testData of tests) { + test(testData.description, async () => await equalityTest(testData, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/types.ts b/packages/nodes-base/nodes/Baserow/types.ts index bb340d3231e..65b5b0a09d0 100644 --- a/packages/nodes-base/nodes/Baserow/types.ts +++ b/packages/nodes-base/nodes/Baserow/types.ts @@ -25,6 +25,7 @@ export type GetAllAdditionalOptions = { export type LoadedResource = { id: number; name: string; + type?: string; }; export type Accumulator = { diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts index 48ee3762183..9a00879bb38 100644 --- a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -234,4 +234,9 @@ BQIDAQAB 'user-read-playback-state playlist-read-collaborative user-modify-playback-state playlist-modify-public user-read-currently-playing playlist-read-private user-read-recently-played playlist-modify-private user-library-read user-follow-read', server: 'https://api.spotify.com/', }, + baserowApi: { + host: 'https://api.baserow.io', + username: 'nathan@n8n.io', + password: 'fake-password', + }, } as const; From cb01f2dd0d018ef54fc63b255abc0a054d01b6ed Mon Sep 17 00:00:00 2001 From: Timothy Degryse <24565568+ownerer@users.noreply.github.com> Date: Mon, 24 Mar 2025 19:04:00 +0200 Subject: [PATCH 15/33] feat(Matrix Node): Add audio and video media types (#14057) --- packages/nodes-base/nodes/Matrix/MediaDescription.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/nodes-base/nodes/Matrix/MediaDescription.ts b/packages/nodes-base/nodes/Matrix/MediaDescription.ts index 3830981641d..06778882f0c 100644 --- a/packages/nodes-base/nodes/Matrix/MediaDescription.ts +++ b/packages/nodes-base/nodes/Matrix/MediaDescription.ts @@ -81,6 +81,16 @@ export const mediaFields: INodeProperties[] = [ value: 'image', description: 'Image media type', }, + { + name: 'Audio', + value: 'audio', + description: 'Audio media type', + }, + { + name: 'Video', + value: 'video', + description: 'Video media type', + }, ], description: 'Type of file being uploaded', placeholder: 'mxc://matrix.org/uploaded-media-uri', From 5bf10cdb4abe364a8f914362fb94841530aa02c9 Mon Sep 17 00:00:00 2001 From: jeanpaul Date: Mon, 24 Mar 2025 19:45:53 +0100 Subject: [PATCH 16/33] fix(OpenAI Node): Show correct inputs for AI node (#14142) --- .../vendors/OpenAi/actions/versionDescription.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts index 64d2c105971..00263163a6f 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts @@ -50,25 +50,22 @@ const configureNodeInputs = ( ) => { if (resource === 'assistant' && operation === 'message') { const inputs: INodeInputConfiguration[] = [ - { type: NodeConnectionTypes.Main }, - { type: NodeConnectionTypes.AiTool, displayName: 'Tools' }, + { type: 'main' }, + { type: 'ai_tool', displayName: 'Tools' }, ]; if (memory !== 'threadId') { - inputs.push({ type: NodeConnectionTypes.AiMemory, displayName: 'Memory', maxConnections: 1 }); + inputs.push({ type: 'ai_memory', displayName: 'Memory', maxConnections: 1 }); } return inputs; } if (resource === 'text' && operation === 'message') { if (hideTools === 'hide') { - return [NodeConnectionTypes.Main]; + return ['main']; } - return [ - { type: NodeConnectionTypes.Main }, - { type: NodeConnectionTypes.AiTool, displayName: 'Tools' }, - ]; + return [{ type: 'main' }, { type: 'ai_tool', displayName: 'Tools' }]; } - return [NodeConnectionTypes.Main]; + return ['main']; }; // eslint-disable-next-line n8n-nodes-base/node-class-description-missing-subtitle From 70764a02589c146bf8e863c9c652ac30858841af Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Tue, 25 Mar 2025 09:08:33 +0100 Subject: [PATCH 17/33] fix: Correct connections in SentimentAnalysis and TextClassifier (#14155) --- .../nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts | 2 +- .../nodes/chains/TextClassifier/TextClassifier.node.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts index 577634ca98f..ac232eeee46 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts @@ -24,7 +24,7 @@ const configuredOutputs = (parameters: INodeParameters, defaultCategories: strin const categories = (options?.categories as string) ?? defaultCategories; const categoriesArray = categories.split(',').map((cat) => cat.trim()); - const ret = categoriesArray.map((cat) => ({ type: NodeConnectionTypes.Main, displayName: cat })); + const ret = categoriesArray.map((cat) => ({ type: 'main', displayName: cat })); return ret; }; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts index 8cc241c294e..bfbaa4c635f 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts @@ -22,9 +22,9 @@ const configuredOutputs = (parameters: INodeParameters) => { const categories = ((parameters.categories as IDataObject)?.categories as IDataObject[]) ?? []; const fallback = (parameters.options as IDataObject)?.fallback as string; const ret = categories.map((cat) => { - return { type: NodeConnectionTypes.Main, displayName: cat.category }; + return { type: 'main', displayName: cat.category }; }); - if (fallback === 'other') ret.push({ type: NodeConnectionTypes.Main, displayName: 'Other' }); + if (fallback === 'other') ret.push({ type: 'main', displayName: 'Other' }); return ret; }; From a082a16c5d785d87ea334bb2285000bd3f44c157 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 25 Mar 2025 08:12:17 +0000 Subject: [PATCH 18/33] fix(Microsoft SQL Node): Fix maximum call stack on execute query (#13940) --- packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts index 1e9f081ca1a..e1056ed5298 100644 --- a/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts +++ b/packages/nodes-base/nodes/Microsoft/Sql/MicrosoftSql.node.ts @@ -269,7 +269,7 @@ export class MicrosoftSql implements INodeType { ); } const results = await executeSqlQueryAndPrepareResults(pool, rawQuery, i); - returnData.push(...results); + returnData = returnData.concat(results); } catch (error) { if (this.continueOnFail()) { returnData.push({ From 71f281b90da6f71db04c9b22dee9e5976b0abab4 Mon Sep 17 00:00:00 2001 From: jeanpaul Date: Tue, 25 Mar 2025 09:13:28 +0100 Subject: [PATCH 19/33] fix(editor): Show left-hand NDV floating nodes in correct order (#14126) --- cypress/e2e/5-ndv.cy.ts | 43 +++++++++- cypress/fixtures/Floating_Nodes.json | 81 +++++++++++++++++++ .../src/components/NDVFloatingNodes.vue | 2 +- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index be784cdc466..4b67a0d9887 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -1,5 +1,10 @@ import { setCredentialValues } from '../composables/modals/credential-modal'; -import { clickCreateNewCredential, setParameterSelectByContent } from '../composables/ndv'; +import { + clickCreateNewCredential, + clickGetBackToCanvas, + setParameterSelectByContent, +} from '../composables/ndv'; +import { openNode } from '../composables/workflow'; import { EDIT_FIELDS_SET_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -617,6 +622,42 @@ describe('NDV', () => { // Sinse code tool require alphanumeric tool name it would also show an error(2 errors, 1 for each tool node) cy.get('[class*=hasIssues]').should('have.length', 3); }); + + it('should have the floating nodes in correct order', () => { + cy.createFixtureWorkflow('Floating_Nodes.json', 'Floating Nodes'); + + cy.ifCanvasVersion( + () => {}, + () => { + // Needed in V2 as all nodes remain selected when clicking on a selected node + workflowPage.actions.deselectAll(); + }, + ); + + // The first merge node has the wires crossed, so `Edit Fields1` is first in the order of connected nodes + openNode('Merge'); + getFloatingNodeByPosition('inputMain').should('exist'); + getFloatingNodeByPosition('inputMain').should('have.length', 2); + getFloatingNodeByPosition('inputMain') + .first() + .should('have.attr', 'data-node-name', 'Edit Fields1'); + getFloatingNodeByPosition('inputMain') + .last() + .should('have.attr', 'data-node-name', 'Edit Fields0'); + + clickGetBackToCanvas(); + + // The second merge node does not have wires crossed, so `Edit Fields0` is first + openNode('Merge1'); + getFloatingNodeByPosition('inputMain').should('exist'); + getFloatingNodeByPosition('inputMain').should('have.length', 2); + getFloatingNodeByPosition('inputMain') + .first() + .should('have.attr', 'data-node-name', 'Edit Fields0'); + getFloatingNodeByPosition('inputMain') + .last() + .should('have.attr', 'data-node-name', 'Edit Fields1'); + }); }); it('should show node name and version in settings', () => { diff --git a/cypress/fixtures/Floating_Nodes.json b/cypress/fixtures/Floating_Nodes.json index 6624c53ac66..01b715e0271 100644 --- a/cypress/fixtures/Floating_Nodes.json +++ b/cypress/fixtures/Floating_Nodes.json @@ -87,6 +87,54 @@ 1600, 740 ] + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.1, + "position": [ + 440, + -140 + ], + "id": "a00959d3-8d4b-40af-b4f2-35ca3d73fd84", + "name": "Merge" + }, + { + "parameters": { + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -20, + -120 + ], + "id": "a5cbc221-ccfd-4034-a648-6a192834af81", + "name": "Edit Fields0" + }, + { + "parameters": { + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 0, + 100 + ], + "id": "d3b4c17a-bee8-418b-a721-5debafd1ce11", + "name": "Edit Fields1" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.1, + "position": [ + 440, + 100 + ], + "id": "b23a2a43-ffac-41a5-a265-054e21a57d70", + "name": "Merge1" } ], "pinData": {}, @@ -161,7 +209,40 @@ } ] ] + }, + "Edit Fields0": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 1 + }, + { + "node": "Merge1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Edit Fields1": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 0 + }, + { + "node": "Merge1", + "type": "main", + "index": 1 + } + ] + ] } + }, "active": false, "settings": { diff --git a/packages/frontend/editor-ui/src/components/NDVFloatingNodes.vue b/packages/frontend/editor-ui/src/components/NDVFloatingNodes.vue index 0265bdb955c..1b76a8def15 100644 --- a/packages/frontend/editor-ui/src/components/NDVFloatingNodes.vue +++ b/packages/frontend/editor-ui/src/components/NDVFloatingNodes.vue @@ -76,7 +76,7 @@ const connectedNodes = computed< ).reverse(), [FloatingNodePosition.left]: getINodesFromNames( workflow.getParentNodes(rootName, NodeConnectionTypes.Main, 1), - ), + ).reverse(), }; }); From 6f60d657eb0dc6ec9e99fe91d5b5cd99662d7ef8 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 25 Mar 2025 09:21:33 +0100 Subject: [PATCH 20/33] fix(API): Fix import config import (#14137) --- packages/cli/src/modules/insights/insights.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/modules/insights/insights.config.ts b/packages/cli/src/modules/insights/insights.config.ts index f273f0e11c9..e8f44984826 100644 --- a/packages/cli/src/modules/insights/insights.config.ts +++ b/packages/cli/src/modules/insights/insights.config.ts @@ -1,4 +1,4 @@ -import { Config, Env } from '@n8n/config/src/decorators'; +import { Config, Env } from '@n8n/config'; @Config export class InsightsConfig { From 4bd42e2f3a8a79c33a20f992e2727f8bb3dae101 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 25 Mar 2025 10:48:49 +0100 Subject: [PATCH 21/33] fix(editor): Remove title icon on Overview subpages (#14128) --- .../components/Projects/ProjectHeader.test.ts | 30 +++++++++++++++++++ .../src/components/Projects/ProjectHeader.vue | 23 ++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts index 3fca181e4c8..57f5c2cd621 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -12,6 +12,7 @@ import { VIEWS } from '@/constants'; import userEvent from '@testing-library/user-event'; import { waitFor, within } from '@testing-library/vue'; import { useSettingsStore } from '@/stores/settings.store'; +import { useOverview } from '@/composables/useOverview'; const mockPush = vi.fn(); vi.mock('vue-router', async () => { @@ -30,6 +31,12 @@ vi.mock('vue-router', async () => { }; }); +vi.mock('@/composables/useOverview', () => ({ + useOverview: vi.fn().mockReturnValue({ + isOverviewSubPage: false, + }), +})); + const projectTabsSpy = vi.fn().mockReturnValue({ render: vi.fn(), }); @@ -45,6 +52,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, { let route: ReturnType; let projectsStore: ReturnType>; let settingsStore: ReturnType>; +let overview: ReturnType; describe('ProjectHeader', () => { beforeEach(() => { @@ -52,6 +60,7 @@ describe('ProjectHeader', () => { route = router.useRoute(); projectsStore = mockedStore(useProjectsStore); settingsStore = mockedStore(useSettingsStore); + overview = useOverview(); projectsStore.teamProjectsLimit = -1; settingsStore.settings.folders = { enabled: false }; @@ -61,6 +70,27 @@ describe('ProjectHeader', () => { vi.clearAllMocks(); }); + it('should not render title icon on overview page', async () => { + vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(true); + const { container } = renderComponent(); + + expect(container.querySelector('.fa-home')).not.toBeInTheDocument(); + }); + + it('should render the correct icon', async () => { + vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(false); + const { container, rerender } = renderComponent(); + + projectsStore.currentProject = { type: ProjectTypes.Personal } as Project; + await rerender({}); + expect(container.querySelector('.fa-user')).toBeVisible(); + + const projectName = 'My Project'; + projectsStore.currentProject = { name: projectName } as Project; + await rerender({}); + expect(container.querySelector('.fa-layer-group')).toBeVisible(); + }); + it('should render the correct title and subtitle', async () => { const { getByText, queryByText, rerender } = renderComponent(); const subtitle = 'All the workflows, credentials and executions you have access to'; diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index 5cd8f35f877..d21fe6591b8 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -4,14 +4,16 @@ import { useRoute, useRouter } from 'vue-router'; import type { UserAction } from '@n8n/design-system'; import { N8nButton, N8nTooltip } from '@n8n/design-system'; import { useI18n } from '@/composables/useI18n'; -import { ProjectTypes } from '@/types/projects.types'; +import { type ProjectIcon as ProjectIconType, ProjectTypes } from '@/types/projects.types'; import { useProjectsStore } from '@/stores/projects.store'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; +import ProjectIcon from '@/components/Projects/ProjectIcon.vue'; import { getResourcePermissions } from '@/permissions'; import { VIEWS } from '@/constants'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue'; import { useSettingsStore } from '@/stores/settings.store'; +import { useOverview } from '@/composables/useOverview'; const route = useRoute(); const router = useRouter(); @@ -19,11 +21,22 @@ const i18n = useI18n(); const projectsStore = useProjectsStore(); const sourceControlStore = useSourceControlStore(); const settingsStore = useSettingsStore(); +const overview = useOverview(); const emit = defineEmits<{ createFolder: []; }>(); +const headerIcon = computed((): ProjectIconType => { + if (projectsStore.currentProject?.type === ProjectTypes.Personal) { + return { type: 'icon', value: 'user' }; + } else if (projectsStore.currentProject?.name) { + return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' }; + } else { + return { type: 'icon', value: 'home' }; + } +}); + const projectName = computed(() => { if (!projectsStore.currentProject) { return i18n.baseText('projects.menu.overview'); @@ -126,6 +139,12 @@ const onSelect = (action: string) => {
+
{{ projectName }} @@ -168,7 +187,7 @@ const onSelect = (action: string) => { .projectHeader, .projectDescription { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; padding-bottom: var(--spacing-m); min-height: var(--spacing-3xl); From 9e3bfe23f67dca8d31bcff8758ed5d076477f9f3 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 25 Mar 2025 05:49:21 -0400 Subject: [PATCH 22/33] fix(editor): Check for when to show the community+ modal for the folder's feature (#14146) Co-authored-by: Milorad Filipovic --- .../frontend/editor-ui/src/views/WorkflowsView.vue | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/views/WorkflowsView.vue index 107eafb4131..ef5f4737f8b 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/views/WorkflowsView.vue @@ -327,11 +327,16 @@ const hasFilters = computed(() => { ); }); -const isCommunity = computed(() => usageStore.planName.toLowerCase() === 'community'); +const isSelfHostedDeployment = computed(() => settingsStore.deploymentType === 'default'); + const canUserRegisterCommunityPlus = computed( () => getResourcePermissions(usersStore.currentUser?.globalScopes).community.register, ); +const showRegisteredCommunityCTA = computed( + () => isSelfHostedDeployment.value && !foldersEnabled.value && canUserRegisterCommunityPlus.value, +); + /** * WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS */ @@ -1042,8 +1047,8 @@ const renameFolder = async (folderId: string) => { }; const createFolderInCurrent = async () => { - // Show the community plus enrollment modal if the user is in a community plan - if (isCommunity.value && canUserRegisterCommunityPlus.value) { + // Show the community plus enrollment modal if the user is self-hosted, and hasn't enabled folders + if (showRegisteredCommunityCTA.value) { uiStore.openModalWithData({ name: COMMUNITY_PLUS_ENROLLMENT_MODAL, data: { customHeading: i18n.baseText('folders.registeredCommunity.cta.heading') }, @@ -1125,7 +1130,7 @@ const moveWorkflowToFolder = async (payload: { name: string; parentFolderId?: string; }) => { - if (isCommunity.value && canUserRegisterCommunityPlus.value) { + if (showRegisteredCommunityCTA.value) { uiStore.openModalWithData({ name: COMMUNITY_PLUS_ENROLLMENT_MODAL, data: { customHeading: i18n.baseText('folders.registeredCommunity.cta.heading') }, From 30e2df321889109743dcb8e9c7ba696892c57904 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 25 Mar 2025 12:15:03 +0200 Subject: [PATCH 23/33] feat(editor): Add support for Simulate nodes in new canvas (no-changelog) (#13675) --- .../editor-ui/src/__tests__/data/canvas.ts | 2 +- .../frontend/editor-ui/src/__tests__/mocks.ts | 5 +++ .../src/components/canvas/Canvas.test.ts | 27 ++++++++++++++ .../src/components/canvas/Canvas.vue | 7 ++-- .../src/composables/useCanvasMapping.ts | 36 +++++++++++++++++-- 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts index ed7a6dc6707..520508fca11 100644 --- a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts @@ -56,7 +56,7 @@ export function createCanvasNodeData({ export function createCanvasNodeElement({ id = '1', - type = 'default', + type = 'canvas-node', label = 'Node', position = { x: 100, y: 100 }, data, diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index c59fa6f893b..8ef9ad3637f 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -23,6 +23,7 @@ import { MANUAL_TRIGGER_NODE_TYPE, NO_OP_NODE_TYPE, SET_NODE_TYPE, + SIMULATE_NODE_TYPE, STICKY_NODE_TYPE, } from '@/constants'; import type { INodeUi, IWorkflowDb } from '@/Interface'; @@ -50,6 +51,7 @@ export const mockNode = ({ export const mockNodeTypeDescription = ({ name = SET_NODE_TYPE, + icon = 'fa:pen', version = 1, credentials = [], inputs = [NodeConnectionTypes.Main], @@ -58,6 +60,7 @@ export const mockNodeTypeDescription = ({ properties = [], }: { name?: INodeTypeDescription['name']; + icon?: INodeTypeDescription['icon']; version?: INodeTypeDescription['version']; credentials?: INodeTypeDescription['credentials']; inputs?: INodeTypeDescription['inputs']; @@ -67,6 +70,7 @@ export const mockNodeTypeDescription = ({ } = {}) => mock({ name, + icon, displayName: name, description: '', version, @@ -102,6 +106,7 @@ export const mockNodes = [ mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }), mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }), mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }), + mockNode({ name: 'Simulate', type: SIMULATE_NODE_TYPE }), mockNode({ name: CanvasNodeRenderType.AddNodes, type: CanvasNodeRenderType.AddNodes }), mockNode({ name: 'End', type: NO_OP_NODE_TYPE }), ]; diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts b/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts index 1ef938faec2..57e273aed6a 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts @@ -8,6 +8,7 @@ import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/dat import { NodeConnectionTypes } from 'n8n-workflow'; import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; import { useVueFlow } from '@vue-flow/core'; +import { SIMULATE_NODE_TYPE } from '@/constants'; const matchMedia = global.window.matchMedia; // @ts-expect-error Initialize window object @@ -273,4 +274,30 @@ describe('Canvas', () => { expect(patternCanvas?.innerHTML).not.toContain(' { + it('should render simulate node', async () => { + const nodes = [ + createCanvasNodeElement({ + id: '1', + label: 'Node', + position: { x: 200, y: 200 }, + data: { + type: SIMULATE_NODE_TYPE, + typeVersion: 1, + }, + }), + ]; + + const { container } = renderComponent({ + props: { + nodes, + }, + }); + + await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1)); + + expect(container.querySelector('.icon')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue index 5808e6ce9c9..f538c0082d5 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue @@ -1,10 +1,7 @@ - - - - diff --git a/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue b/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue index 038bbf71f30..c02d02064da 100644 --- a/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue +++ b/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue @@ -1,8 +1,6 @@