feat(core): Execution curation (#10342)

Co-authored-by: oleg <me@olegivaniv.com>
This commit is contained in:
Eugene 2024-09-02 15:20:08 +02:00 committed by GitHub
parent 8603946e23
commit 022ddcbef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 2733 additions and 713 deletions

View file

@ -233,9 +233,11 @@ const createMockExecutions = () => {
executionsTab.actions.createManualExecutions(5); executionsTab.actions.createManualExecutions(5);
// Make some failed executions by enabling Code node with syntax error // Make some failed executions by enabling Code node with syntax error
executionsTab.actions.toggleNodeEnabled('Error'); executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 0);
executionsTab.actions.createManualExecutions(2); executionsTab.actions.createManualExecutions(2);
// Then add some more successful ones // Then add some more successful ones
executionsTab.actions.toggleNodeEnabled('Error'); executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 1);
executionsTab.actions.createManualExecutions(4); executionsTab.actions.createManualExecutions(4);
}; };

View file

@ -582,7 +582,13 @@ describe('NDV', () => {
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib'); ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib');
ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON'); ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON');
ndv.getters.outputDisplayMode().find('label').eq(1).click(); ndv.getters
.outputDisplayMode()
.find('label')
.eq(1)
.scrollIntoView()
.should('be.visible')
.click();
ndv.getters.outputDataContainer().find('.json-data').should('exist'); ndv.getters.outputDataContainer().find('.json-data').should('exist');
ndv.getters ndv.getters

View file

@ -1,5 +1,6 @@
export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const; export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const;
export const RESOURCES = { export const RESOURCES = {
annotationTag: [...DEFAULT_OPERATIONS] as const,
auditLogs: ['manage'] as const, auditLogs: ['manage'] as const,
banner: ['dismiss'] as const, banner: ['dismiss'] as const,
communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const, communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const,

View file

@ -10,6 +10,7 @@ export type ResourceScope<
export type WildcardScope = `${Resource}:*` | '*'; export type WildcardScope = `${Resource}:*` | '*';
export type AnnotationTagScope = ResourceScope<'annotationTag'>;
export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>; export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>;
export type BannerScope = ResourceScope<'banner', 'dismiss'>; export type BannerScope = ResourceScope<'banner', 'dismiss'>;
export type CommunityPackageScope = ResourceScope< export type CommunityPackageScope = ResourceScope<
@ -44,6 +45,7 @@ export type WorkflowScope = ResourceScope<
>; >;
export type Scope = export type Scope =
| AnnotationTagScope
| AuditLogsScope | AuditLogsScope
| BannerScope | BannerScope
| CommunityPackageScope | CommunityPackageScope

View file

@ -0,0 +1,45 @@
import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators';
import { AnnotationTagService } from '@/services/annotation-tag.service';
import { AnnotationTagsRequest } from '@/requests';
@RestController('/annotation-tags')
export class AnnotationTagsController {
constructor(private readonly annotationTagService: AnnotationTagService) {}
@Get('/')
@GlobalScope('annotationTag:list')
async getAll(req: AnnotationTagsRequest.GetAll) {
return await this.annotationTagService.getAll({
withUsageCount: req.query.withUsageCount === 'true',
});
}
@Post('/')
@GlobalScope('annotationTag:create')
async createTag(req: AnnotationTagsRequest.Create) {
const tag = this.annotationTagService.toEntity({ name: req.body.name });
return await this.annotationTagService.save(tag);
}
@Patch('/:id(\\w+)')
@GlobalScope('annotationTag:update')
async updateTag(req: AnnotationTagsRequest.Update) {
const newTag = this.annotationTagService.toEntity({
id: req.params.id,
name: req.body.name.trim(),
});
return await this.annotationTagService.save(newTag);
}
@Delete('/:id(\\w+)')
@GlobalScope('annotationTag:delete')
async deleteTag(req: AnnotationTagsRequest.Delete) {
const { id } = req.params;
await this.annotationTagService.delete(id);
return true;
}
}

View file

@ -62,6 +62,7 @@ const getSqliteConnectionOptions = (): SqliteConnectionOptions | SqlitePooledCon
database: path.resolve(Container.get(InstanceSettings).n8nFolder, sqliteConfig.database), database: path.resolve(Container.get(InstanceSettings).n8nFolder, sqliteConfig.database),
migrations: sqliteMigrations, migrations: sqliteMigrations,
}; };
if (sqliteConfig.poolSize > 0) { if (sqliteConfig.poolSize > 0) {
return { return {
type: 'sqlite-pooled', type: 'sqlite-pooled',

View file

@ -0,0 +1,20 @@
import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm';
import { IsString, Length } from 'class-validator';
import { WithTimestampsAndStringId } from './abstract-entity';
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
@Entity()
export class AnnotationTagEntity extends WithTimestampsAndStringId {
@Column({ length: 24 })
@Index({ unique: true })
@IsString({ message: 'Tag name must be of type string.' })
@Length(1, 24, { message: 'Tag name must be $constraint1 to $constraint2 characters long.' })
name: string;
@ManyToMany('ExecutionAnnotation', 'tags')
annotations: ExecutionAnnotation[];
@OneToMany('AnnotationTagMapping', 'tags')
annotationMappings: AnnotationTagMapping[];
}

View file

@ -0,0 +1,23 @@
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import type { ExecutionAnnotation } from './execution-annotation';
import type { AnnotationTagEntity } from './annotation-tag-entity';
/**
* This entity represents the junction table between the execution annotations and the tags
*/
@Entity({ name: 'execution_annotation_tags' })
export class AnnotationTagMapping {
@PrimaryColumn()
annotationId: number;
@ManyToOne('ExecutionAnnotation', 'tagMappings')
@JoinColumn({ name: 'annotationId' })
annotations: ExecutionAnnotation[];
@PrimaryColumn()
tagId: string;
@ManyToOne('AnnotationTagEntity', 'annotationMappings')
@JoinColumn({ name: 'tagId' })
tags: AnnotationTagEntity[];
}

View file

@ -0,0 +1,61 @@
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
RelationId,
} from '@n8n/typeorm';
import { ExecutionEntity } from './execution-entity';
import type { AnnotationTagEntity } from './annotation-tag-entity';
import type { AnnotationTagMapping } from './annotation-tag-mapping';
import type { AnnotationVote } from 'n8n-workflow';
@Entity({ name: 'execution_annotations' })
export class ExecutionAnnotation {
@PrimaryGeneratedColumn()
id: number;
/**
* This field stores the up- or down-vote of the execution by user.
*/
@Column({ type: 'varchar', nullable: true })
vote: AnnotationVote | null;
/**
* Custom text note added to the execution by user.
*/
@Column({ type: 'varchar', nullable: true })
note: string | null;
@RelationId((annotation: ExecutionAnnotation) => annotation.execution)
executionId: string;
@Index({ unique: true })
@OneToOne('ExecutionEntity', 'annotation', {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'executionId' })
execution: ExecutionEntity;
@ManyToMany('AnnotationTagEntity', 'annotations')
@JoinTable({
name: 'execution_annotation_tags', // table name for the junction table of this relation
joinColumn: {
name: 'annotationId',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'tagId',
referencedColumnName: 'id',
},
})
tags?: AnnotationTagEntity[];
@OneToMany('AnnotationTagMapping', 'annotations')
tagMappings: AnnotationTagMapping[];
}

View file

@ -16,6 +16,7 @@ import { idStringifier } from '../utils/transformers';
import type { ExecutionData } from './execution-data'; import type { ExecutionData } from './execution-data';
import type { ExecutionMetadata } from './execution-metadata'; import type { ExecutionMetadata } from './execution-metadata';
import { WorkflowEntity } from './workflow-entity'; import { WorkflowEntity } from './workflow-entity';
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
@Entity() @Entity()
@Index(['workflowId', 'id']) @Index(['workflowId', 'id'])
@ -65,6 +66,9 @@ export class ExecutionEntity {
@OneToOne('ExecutionData', 'execution') @OneToOne('ExecutionData', 'execution')
executionData: Relation<ExecutionData>; executionData: Relation<ExecutionData>;
@OneToOne('ExecutionAnnotation', 'execution')
annotation?: Relation<ExecutionAnnotation>;
@ManyToOne('WorkflowEntity') @ManyToOne('WorkflowEntity')
workflow: WorkflowEntity; workflow: WorkflowEntity;
} }

View file

@ -22,13 +22,19 @@ import { WorkflowHistory } from './workflow-history';
import { Project } from './project'; import { Project } from './project';
import { ProjectRelation } from './project-relation'; import { ProjectRelation } from './project-relation';
import { InvalidAuthToken } from './invalid-auth-token'; import { InvalidAuthToken } from './invalid-auth-token';
import { AnnotationTagEntity } from './annotation-tag-entity';
import { AnnotationTagMapping } from './annotation-tag-mapping';
import { ExecutionAnnotation } from './execution-annotation';
export const entities = { export const entities = {
AnnotationTagEntity,
AnnotationTagMapping,
AuthIdentity, AuthIdentity,
AuthProviderSyncHistory, AuthProviderSyncHistory,
AuthUser, AuthUser,
CredentialsEntity, CredentialsEntity,
EventDestinations, EventDestinations,
ExecutionAnnotation,
ExecutionEntity, ExecutionEntity,
InstalledNodes, InstalledNodes,
InstalledPackages, InstalledPackages,

View file

@ -0,0 +1,48 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
const annotationsTableName = 'execution_annotations';
const annotationTagsTableName = 'annotation_tag_entity';
const annotationTagMappingsTableName = 'execution_annotation_tags';
export class CreateAnnotationTables1724753530828 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(annotationsTableName)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('executionId').int.notNull,
column('vote').varchar(6),
column('note').text,
)
.withIndexOn('executionId', true)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await createTable(annotationTagsTableName)
.withColumns(column('id').varchar(16).primary.notNull, column('name').varchar(24).notNull)
.withIndexOn('name', true).withTimestamps;
await createTable(annotationTagMappingsTableName)
.withColumns(column('annotationId').int.notNull, column('tagId').varchar(24).notNull)
.withForeignKey('annotationId', {
tableName: annotationsTableName,
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn('tagId')
.withIndexOn('annotationId')
.withForeignKey('tagId', {
tableName: annotationTagsTableName,
columnName: 'id',
onDelete: 'CASCADE',
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(annotationTagMappingsTableName);
await dropTable(annotationTagsTableName);
await dropTable(annotationsTableName);
}
}

View file

@ -61,6 +61,7 @@ import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActiv
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata'; import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -125,4 +126,5 @@ export const mysqlMigrations: Migration[] = [
AddConstraintToExecutionMetadata1720101653148, AddConstraintToExecutionMetadata1720101653148,
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828,
]; ];

View file

@ -61,6 +61,7 @@ import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-R
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata'; import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence'; import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence';
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
export const postgresMigrations: Migration[] = [ export const postgresMigrations: Migration[] = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -125,4 +126,5 @@ export const postgresMigrations: Migration[] = [
FixExecutionMetadataSequence1721377157740, FixExecutionMetadataSequence1721377157740,
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828,
]; ];

View file

@ -58,6 +58,7 @@ import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActiv
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata'; import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
const sqliteMigrations: Migration[] = [ const sqliteMigrations: Migration[] = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -119,6 +120,7 @@ const sqliteMigrations: Migration[] = [
AddConstraintToExecutionMetadata1720101653148, AddConstraintToExecutionMetadata1720101653148,
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -0,0 +1,26 @@
import { Service } from 'typedi';
import { DataSource, Repository } from '@n8n/typeorm';
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
@Service()
export class AnnotationTagMappingRepository extends Repository<AnnotationTagMapping> {
constructor(dataSource: DataSource) {
super(AnnotationTagMapping, dataSource.manager);
}
/**
* Overwrite annotation tags for the given execution. Annotation should already exist.
*/
async overwriteTags(annotationId: number, tagIds: string[]) {
return await this.manager.transaction(async (tx) => {
await tx.delete(AnnotationTagMapping, { annotationId });
const tagMappings = tagIds.map((tagId) => ({
annotationId,
tagId,
}));
return await tx.insert(AnnotationTagMapping, tagMappings);
});
}
}

View file

@ -0,0 +1,10 @@
import { Service } from 'typedi';
import { DataSource, Repository } from '@n8n/typeorm';
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
@Service()
export class AnnotationTagRepository extends Repository<AnnotationTagEntity> {
constructor(dataSource: DataSource) {
super(AnnotationTagEntity, dataSource.manager);
}
}

View file

@ -0,0 +1,10 @@
import { Service } from 'typedi';
import { DataSource, Repository } from '@n8n/typeorm';
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
@Service()
export class ExecutionAnnotationRepository extends Repository<ExecutionAnnotation> {
constructor(dataSource: DataSource) {
super(ExecutionAnnotation, dataSource.manager);
}
}

View file

@ -1,4 +1,5 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import pick from 'lodash/pick';
import { import {
Brackets, Brackets,
DataSource, DataSource,
@ -21,14 +22,18 @@ import type {
} from '@n8n/typeorm'; } from '@n8n/typeorm';
import { parse, stringify } from 'flatted'; import { parse, stringify } from 'flatted';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import {
ApplicationError,
type ExecutionStatus,
type ExecutionSummary,
type IRunExecutionData,
} from 'n8n-workflow';
import { BinaryDataService } from 'n8n-core'; import { BinaryDataService } from 'n8n-core';
import { ExecutionCancelledError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import {
ExecutionCancelledError,
ErrorReporterProxy as ErrorReporter,
ApplicationError,
} from 'n8n-workflow';
import type {
AnnotationVote,
ExecutionStatus,
ExecutionSummary,
IRunExecutionData,
} from 'n8n-workflow';
import type { import type {
ExecutionPayload, ExecutionPayload,
@ -46,6 +51,9 @@ import { Logger } from '@/logger';
import type { ExecutionSummaries } from '@/executions/execution.types'; import type { ExecutionSummaries } from '@/executions/execution.types';
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error'; import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
import { separate } from '@/utils'; import { separate } from '@/utils';
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
export interface IGetExecutionsQueryFilter { export interface IGetExecutionsQueryFilter {
id?: FindOperator<string> | string; id?: FindOperator<string> | string;
@ -201,10 +209,22 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
); );
} }
private serializeAnnotation(annotation: ExecutionEntity['annotation']) {
if (!annotation) return null;
const { id, vote, tags } = annotation;
return {
id,
vote,
tags: tags?.map((tag) => pick(tag, ['id', 'name'])) ?? [],
};
}
async findSingleExecution( async findSingleExecution(
id: string, id: string,
options?: { options?: {
includeData: true; includeData: true;
includeAnnotation?: boolean;
unflattenData: true; unflattenData: true;
where?: FindOptionsWhere<ExecutionEntity>; where?: FindOptionsWhere<ExecutionEntity>;
}, },
@ -213,6 +233,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
id: string, id: string,
options?: { options?: {
includeData: true; includeData: true;
includeAnnotation?: boolean;
unflattenData?: false | undefined; unflattenData?: false | undefined;
where?: FindOptionsWhere<ExecutionEntity>; where?: FindOptionsWhere<ExecutionEntity>;
}, },
@ -221,6 +242,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
id: string, id: string,
options?: { options?: {
includeData?: boolean; includeData?: boolean;
includeAnnotation?: boolean;
unflattenData?: boolean; unflattenData?: boolean;
where?: FindOptionsWhere<ExecutionEntity>; where?: FindOptionsWhere<ExecutionEntity>;
}, },
@ -229,6 +251,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
id: string, id: string,
options?: { options?: {
includeData?: boolean; includeData?: boolean;
includeAnnotation?: boolean;
unflattenData?: boolean; unflattenData?: boolean;
where?: FindOptionsWhere<ExecutionEntity>; where?: FindOptionsWhere<ExecutionEntity>;
}, },
@ -240,7 +263,16 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
}, },
}; };
if (options?.includeData) { if (options?.includeData) {
findOptions.relations = ['executionData', 'metadata']; findOptions.relations = { executionData: true, metadata: true };
}
if (options?.includeAnnotation) {
findOptions.relations = {
...findOptions.relations,
annotation: {
tags: true,
},
};
} }
const execution = await this.findOne(findOptions); const execution = await this.findOne(findOptions);
@ -249,25 +281,21 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
return undefined; return undefined;
} }
const { executionData, metadata, ...rest } = execution; const { executionData, metadata, annotation, ...rest } = execution;
const serializedAnnotation = this.serializeAnnotation(annotation);
if (options?.includeData && options?.unflattenData) {
return { return {
...rest, ...rest,
data: parse(execution.executionData.data) as IRunExecutionData, ...(options?.includeData && {
workflowData: execution.executionData.workflowData, data: options?.unflattenData
? (parse(executionData.data) as IRunExecutionData)
: executionData.data,
workflowData: executionData?.workflowData,
customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])),
} as IExecutionResponse; }),
} else if (options?.includeData) { ...(options?.includeAnnotation &&
return { serializedAnnotation && { annotation: serializedAnnotation }),
...rest, };
data: execution.executionData.data,
workflowData: execution.executionData.workflowData,
customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])),
} as IExecutionFlattedDb;
}
return rest;
} }
/** /**
@ -410,6 +438,13 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h
const maxCount = config.getEnv('executions.pruneDataMaxCount'); const maxCount = config.getEnv('executions.pruneDataMaxCount');
// Sub-query to exclude executions having annotations
const annotatedExecutionsSubQuery = this.manager
.createQueryBuilder()
.subQuery()
.select('annotation.executionId')
.from(ExecutionAnnotation, 'annotation');
// Find ids of all executions that were stopped longer that pruneDataMaxAge ago // Find ids of all executions that were stopped longer that pruneDataMaxAge ago
const date = new Date(); const date = new Date();
date.setHours(date.getHours() - maxAge); date.setHours(date.getHours() - maxAge);
@ -420,12 +455,13 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
]; ];
if (maxCount > 0) { if (maxCount > 0) {
const executions = await this.find({ const executions = await this.createQueryBuilder('execution')
select: ['id'], .select('execution.id')
skip: maxCount, .where('execution.id NOT IN ' + annotatedExecutionsSubQuery.getQuery())
take: 1, .skip(maxCount)
order: { id: 'DESC' }, .take(1)
}); .orderBy('execution.id', 'DESC')
.getMany();
if (executions[0]) { if (executions[0]) {
toPrune.push({ id: LessThanOrEqual(executions[0].id) }); toPrune.push({ id: LessThanOrEqual(executions[0].id) });
@ -442,6 +478,8 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
// Only mark executions as deleted if they are in an end state // Only mark executions as deleted if they are in an end state
status: Not(In(['new', 'running', 'waiting'])), status: Not(In(['new', 'running', 'waiting'])),
}) })
// Only mark executions as deleted if they are not annotated
.andWhere('id NOT IN ' + annotatedExecutionsSubQuery.getQuery())
.andWhere( .andWhere(
new Brackets((qb) => new Brackets((qb) =>
countBasedWhere countBasedWhere
@ -612,6 +650,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
}, },
includeData: true, includeData: true,
unflattenData: true, unflattenData: true,
includeAnnotation: true,
}); });
} }
@ -622,6 +661,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
}, },
includeData: true, includeData: true,
unflattenData: false, unflattenData: false,
includeAnnotation: true,
}); });
} }
@ -683,12 +723,80 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
stoppedAt: true, stoppedAt: true,
}; };
private annotationFields = {
id: true,
vote: true,
};
/**
* This function reduces duplicate rows in the raw result set of the query builder from *toQueryBuilderWithAnnotations*
* by merging the tags of the same execution annotation.
*/
private reduceExecutionsWithAnnotations(
rawExecutionsWithTags: Array<
ExecutionSummary & {
annotation_id: number;
annotation_vote: AnnotationVote;
annotation_tags_id: string;
annotation_tags_name: string;
}
>,
) {
return rawExecutionsWithTags.reduce(
(
acc,
{
annotation_id: _,
annotation_vote: vote,
annotation_tags_id: tagId,
annotation_tags_name: tagName,
...row
},
) => {
const existingExecution = acc.find((e) => e.id === row.id);
if (existingExecution) {
if (tagId) {
existingExecution.annotation = existingExecution.annotation ?? {
vote,
tags: [] as Array<{ id: string; name: string }>,
};
existingExecution.annotation.tags.push({ id: tagId, name: tagName });
}
} else {
acc.push({
...row,
annotation: {
vote,
tags: tagId ? [{ id: tagId, name: tagName }] : [],
},
});
}
return acc;
},
[] as ExecutionSummary[],
);
}
async findManyByRangeQuery(query: ExecutionSummaries.RangeQuery): Promise<ExecutionSummary[]> { async findManyByRangeQuery(query: ExecutionSummaries.RangeQuery): Promise<ExecutionSummary[]> {
if (query?.accessibleWorkflowIds?.length === 0) { if (query?.accessibleWorkflowIds?.length === 0) {
throw new ApplicationError('Expected accessible workflow IDs'); throw new ApplicationError('Expected accessible workflow IDs');
} }
const executions: ExecutionSummary[] = await this.toQueryBuilder(query).getRawMany(); // Due to performance reasons, we use custom query builder with raw SQL.
// IMPORTANT: it produces duplicate rows for executions with multiple tags, which we need to reduce manually
const qb = this.toQueryBuilderWithAnnotations(query);
const rawExecutionsWithTags: Array<
ExecutionSummary & {
annotation_id: number;
annotation_vote: AnnotationVote;
annotation_tags_id: string;
annotation_tags_name: string;
}
> = await qb.getRawMany();
const executions = this.reduceExecutionsWithAnnotations(rawExecutionsWithTags);
return executions.map((execution) => this.toSummary(execution)); return executions.map((execution) => this.toSummary(execution));
} }
@ -764,6 +872,8 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
startedBefore, startedBefore,
startedAfter, startedAfter,
metadata, metadata,
annotationTags,
vote,
} = query; } = query;
const fields = Object.keys(this.summaryFields) const fields = Object.keys(this.summaryFields)
@ -812,9 +922,62 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
qb.setParameter('value', value); qb.setParameter('value', value);
} }
if (annotationTags?.length || vote) {
// If there is a filter by one or multiple tags or by vote - we need to join the annotations table
qb.innerJoin('execution.annotation', 'annotation');
// Add an inner join for each tag
if (annotationTags?.length) {
for (let index = 0; index < annotationTags.length; index++) {
qb.innerJoin(
AnnotationTagMapping,
`atm_${index}`,
`atm_${index}.annotationId = annotation.id AND atm_${index}.tagId = :tagId_${index}`,
);
qb.setParameter(`tagId_${index}`, annotationTags[index]);
}
}
// Add filter by vote
if (vote) {
qb.andWhere('annotation.vote = :vote', { vote });
}
}
return qb; return qb;
} }
/**
* This method is used to add the annotation fields to the executions query
* It uses original query builder as a subquery and adds the annotation fields to it
* IMPORTANT: Query made with this query builder fetches duplicate execution rows for each tag,
* this is intended, as we are working with raw query.
* The duplicates are reduced in the *reduceExecutionsWithAnnotations* method.
*/
private toQueryBuilderWithAnnotations(query: ExecutionSummaries.Query) {
const annotationFields = Object.keys(this.annotationFields).map(
(key) => `annotation.${key} AS "annotation_${key}"`,
);
const subQuery = this.toQueryBuilder(query).addSelect(annotationFields);
// Ensure the join with annotations is made only once
// It might be already present as an inner join if the query includes filter by annotation tags
// If not, it must be added as a left join
if (!subQuery.expressionMap.joinAttributes.some((join) => join.alias.name === 'annotation')) {
subQuery.leftJoin('execution.annotation', 'annotation');
}
return this.manager
.createQueryBuilder()
.select(['e.*', 'ate.id AS "annotation_tags_id"', 'ate.name AS "annotation_tags_name"'])
.from(`(${subQuery.getQuery()})`, 'e')
.setParameters(subQuery.getParameters())
.leftJoin(AnnotationTagMapping, 'atm', 'atm.annotationId = e.annotation_id')
.leftJoin(AnnotationTagEntity, 'ate', 'ate.id = atm.tagId');
}
async getAllIds() { async getAllIds() {
const executions = await this.find({ select: ['id'], order: { id: 'ASC' } }); const executions = await this.find({ select: ['id'], order: { id: 'ASC' } });

View file

@ -25,6 +25,8 @@ describe('ExecutionService', () => {
mock(), mock(),
mock(), mock(),
activeExecutions, activeExecutions,
mock(),
mock(),
executionRepository, executionRepository,
mock(), mock(),
mock(), mock(),

View file

@ -2,12 +2,12 @@ import { Container, Service } from 'typedi';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { validate as jsonSchemaValidate } from 'jsonschema'; import { validate as jsonSchemaValidate } from 'jsonschema';
import type { import type {
IWorkflowBase,
ExecutionError, ExecutionError,
ExecutionStatus,
INode, INode,
IRunExecutionData, IRunExecutionData,
IWorkflowBase,
WorkflowExecuteMode, WorkflowExecuteMode,
ExecutionStatus,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
ApplicationError, ApplicationError,
@ -41,6 +41,8 @@ import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.err
import { License } from '@/license'; import { License } from '@/license';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository';
import { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository';
export const schemaGetExecutionsQueryFilter = { export const schemaGetExecutionsQueryFilter = {
$id: '/IGetExecutionsQueryFilter', $id: '/IGetExecutionsQueryFilter',
@ -60,6 +62,8 @@ export const schemaGetExecutionsQueryFilter = {
metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } }, metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } },
startedAfter: { type: 'date-time' }, startedAfter: { type: 'date-time' },
startedBefore: { type: 'date-time' }, startedBefore: { type: 'date-time' },
annotationTags: { type: 'array', items: { type: 'string' } },
vote: { type: 'string' },
}, },
$defs: { $defs: {
metadata: { metadata: {
@ -85,6 +89,8 @@ export class ExecutionService {
private readonly globalConfig: GlobalConfig, private readonly globalConfig: GlobalConfig,
private readonly logger: Logger, private readonly logger: Logger,
private readonly activeExecutions: ActiveExecutions, private readonly activeExecutions: ActiveExecutions,
private readonly executionAnnotationRepository: ExecutionAnnotationRepository,
private readonly annotationTagMappingRepository: AnnotationTagMappingRepository,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
@ -96,7 +102,7 @@ export class ExecutionService {
) {} ) {}
async findOne( async findOne(
req: ExecutionRequest.GetOne, req: ExecutionRequest.GetOne | ExecutionRequest.Update,
sharedWorkflowIds: string[], sharedWorkflowIds: string[],
): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> { ): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> {
if (!sharedWorkflowIds.length) return undefined; if (!sharedWorkflowIds.length) return undefined;
@ -495,4 +501,42 @@ export class ExecutionService {
s.scopes = scopes[s.workflowId] ?? []; s.scopes = scopes[s.workflowId] ?? [];
} }
} }
public async annotate(
executionId: string,
updateData: ExecutionRequest.ExecutionUpdatePayload,
sharedWorkflowIds: string[],
) {
// Check if user can access the execution
const execution = await this.executionRepository.findIfAccessible(
executionId,
sharedWorkflowIds,
);
if (!execution) {
this.logger.info('Attempt to read execution was blocked due to insufficient permissions', {
executionId,
});
throw new NotFoundError('Execution not found');
}
// Create or update execution annotation
await this.executionAnnotationRepository.upsert(
{ execution: { id: executionId }, vote: updateData.vote },
['execution'],
);
// Upsert behavior differs for Postgres, MySQL and sqlite,
// so we need to fetch the annotation to get the ID
const annotation = await this.executionAnnotationRepository.findOneOrFail({
where: {
execution: { id: executionId },
},
});
if (updateData.tags) {
await this.annotationTagMappingRepository.overwriteTags(annotation.id, updateData.tags);
}
}
} }

View file

@ -2,6 +2,7 @@ import type { ExecutionEntity } from '@/databases/entities/execution-entity';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import type { import type {
AnnotationVote,
ExecutionStatus, ExecutionStatus,
ExecutionSummary, ExecutionSummary,
IDataObject, IDataObject,
@ -34,6 +35,11 @@ export declare namespace ExecutionRequest {
}; };
} }
type ExecutionUpdatePayload = {
tags?: string[];
vote?: AnnotationVote | null;
};
type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & { type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & {
rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params
}; };
@ -45,6 +51,8 @@ export declare namespace ExecutionRequest {
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>; type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>;
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>; type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;
type Update = AuthenticatedRequest<RouteParams.ExecutionId, {}, ExecutionUpdatePayload, {}>;
} }
export namespace ExecutionSummaries { export namespace ExecutionSummaries {
@ -69,6 +77,8 @@ export namespace ExecutionSummaries {
metadata: Array<{ key: string; value: string }>; metadata: Array<{ key: string; value: string }>;
startedAfter: string; startedAfter: string;
startedBefore: string; startedBefore: string;
annotationTags: string[]; // tag IDs
vote: AnnotationVote;
}>; }>;
type AccessFields = { type AccessFields = {

View file

@ -1,6 +1,7 @@
import { ExecutionRequest, type ExecutionSummaries } from './execution.types'; import { ExecutionRequest, type ExecutionSummaries } from './execution.types';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { Get, Post, RestController } from '@/decorators'; import { validateExecutionUpdatePayload } from './validation';
import { Get, Patch, Post, RestController } from '@/decorators';
import { EnterpriseExecutionsService } from './execution.service.ee'; import { EnterpriseExecutionsService } from './execution.service.ee';
import { License } from '@/license'; import { License } from '@/license';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
@ -47,7 +48,10 @@ export class ExecutionsController {
query.accessibleWorkflowIds = accessibleWorkflowIds; query.accessibleWorkflowIds = accessibleWorkflowIds;
if (!this.license.isAdvancedExecutionFiltersEnabled()) delete query.metadata; if (!this.license.isAdvancedExecutionFiltersEnabled()) {
delete query.metadata;
delete query.annotationTags;
}
const noStatus = !query.status || query.status.length === 0; const noStatus = !query.status || query.status.length === 0;
const noRange = !query.range.lastId || !query.range.firstId; const noRange = !query.range.lastId || !query.range.firstId;
@ -110,4 +114,23 @@ export class ExecutionsController {
return await this.executionService.delete(req, workflowIds); return await this.executionService.delete(req, workflowIds);
} }
@Patch('/:id')
async update(req: ExecutionRequest.Update) {
if (!isPositiveInteger(req.params.id)) {
throw new BadRequestError('Execution ID is not a number');
}
const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:read');
// Fail fast if no workflows are accessible
if (workflowIds.length === 0) throw new NotFoundError('Execution not found');
const { body: payload } = req;
const validatedPayload = validateExecutionUpdatePayload(payload);
await this.executionService.annotate(req.params.id, validatedPayload, workflowIds);
return await this.executionService.findOne(req, workflowIds);
}
} }

View file

@ -0,0 +1,30 @@
import { z } from 'zod';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { ExecutionRequest } from '@/executions/execution.types';
const executionUpdateSchema = z.object({
tags: z.array(z.string()).optional(),
vote: z.enum(['up', 'down']).nullable().optional(),
});
export function validateExecutionUpdatePayload(
payload: unknown,
): ExecutionRequest.ExecutionUpdatePayload {
try {
const validatedPayload = executionUpdateSchema.parse(payload);
// Additional check to ensure that at least one property is provided
const { tags, vote } = validatedPayload;
if (!tags && vote === undefined) {
throw new BadRequestError('No annotation provided');
}
return validatedPayload;
} catch (e) {
if (e instanceof z.ZodError) {
throw new BadRequestError(e.message);
}
throw e;
}
}

View file

@ -1,6 +1,7 @@
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
import type { TagEntity } from '@/databases/entities/tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import type { import type {
@ -16,6 +17,7 @@ export async function validateEntity(
| WorkflowEntity | WorkflowEntity
| CredentialsEntity | CredentialsEntity
| TagEntity | TagEntity
| AnnotationTagEntity
| User | User
| UserUpdatePayload | UserUpdatePayload
| UserRoleChangePayload | UserRoleChangePayload

View file

@ -31,6 +31,7 @@ import type { WorkflowExecute } from 'n8n-core';
import type PCancelable from 'p-cancelable'; import type PCancelable from 'p-cancelable';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
import type { AuthProviderType } from '@/databases/entities/auth-identity'; import type { AuthProviderType } from '@/databases/entities/auth-identity';
import type { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { SharedCredentials } from '@/databases/entities/shared-credentials';
import type { TagEntity } from '@/databases/entities/tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity';
@ -57,9 +58,12 @@ export interface ICredentialsOverwrite {
// tags // tags
// ---------------------------------- // ----------------------------------
export interface ITagToImport { export interface ITagBase {
id: string; id: string;
name: string; name: string;
}
export interface ITagToImport extends ITagBase {
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@ -68,8 +72,13 @@ export type UsageCount = {
usageCount: number; usageCount: number;
}; };
export type ITagWithCountDb = Pick<TagEntity, 'id' | 'name' | 'createdAt' | 'updatedAt'> & export type ITagDb = Pick<TagEntity, 'id' | 'name' | 'createdAt' | 'updatedAt'>;
UsageCount;
export type ITagWithCountDb = ITagDb & UsageCount;
export type IAnnotationTagDb = Pick<AnnotationTagEntity, 'id' | 'name' | 'createdAt' | 'updatedAt'>;
export type IAnnotationTagWithCountDb = IAnnotationTagDb & UsageCount;
// ---------------------------------- // ----------------------------------
// workflows // workflows
@ -145,6 +154,9 @@ export interface IExecutionResponse extends IExecutionBase {
retrySuccessId?: string; retrySuccessId?: string;
workflowData: IWorkflowBase | WorkflowWithSharingsAndCredentials; workflowData: IWorkflowBase | WorkflowWithSharingsAndCredentials;
customData: Record<string, string>; customData: Record<string, string>;
annotation: {
tags: ITagBase[];
};
} }
// Flatted data to save memory when saving in database or transferring // Flatted data to save memory when saving in database or transferring

View file

@ -1,6 +1,11 @@
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
export const GLOBAL_OWNER_SCOPES: Scope[] = [ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'annotationTag:create',
'annotationTag:read',
'annotationTag:update',
'annotationTag:delete',
'annotationTag:list',
'auditLogs:manage', 'auditLogs:manage',
'banner:dismiss', 'banner:dismiss',
'credential:create', 'credential:create',
@ -75,6 +80,11 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
export const GLOBAL_MEMBER_SCOPES: Scope[] = [ export const GLOBAL_MEMBER_SCOPES: Scope[] = [
'annotationTag:create',
'annotationTag:read',
'annotationTag:update',
'annotationTag:delete',
'annotationTag:list',
'eventBusDestination:list', 'eventBusDestination:list',
'eventBusDestination:test', 'eventBusDestination:test',
'tag:create', 'tag:create',

View file

@ -448,6 +448,17 @@ export declare namespace TagsRequest {
type Delete = AuthenticatedRequest<{ id: string }>; type Delete = AuthenticatedRequest<{ id: string }>;
} }
// ----------------------------------
// /annotation-tags
// ----------------------------------
export declare namespace AnnotationTagsRequest {
type GetAll = AuthenticatedRequest<{}, {}, {}, { withUsageCount: string }>;
type Create = AuthenticatedRequest<{}, {}, { name: string }>;
type Update = AuthenticatedRequest<{ id: string }, {}, { name: string }>;
type Delete = AuthenticatedRequest<{ id: string }>;
}
// ---------------------------------- // ----------------------------------
// /nodes // /nodes
// ---------------------------------- // ----------------------------------

View file

@ -38,6 +38,7 @@ import { OrchestrationService } from '@/services/orchestration.service';
import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay';
import '@/controllers/active-workflows.controller'; import '@/controllers/active-workflows.controller';
import '@/controllers/annotation-tags.controller';
import '@/controllers/auth.controller'; import '@/controllers/auth.controller';
import '@/controllers/binary-data.controller'; import '@/controllers/binary-data.controller';
import '@/controllers/curl.controller'; import '@/controllers/curl.controller';

View file

@ -0,0 +1,52 @@
import { Service } from 'typedi';
import { validateEntity } from '@/generic-helpers';
import type { IAnnotationTagDb, IAnnotationTagWithCountDb } from '@/interfaces';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository';
type GetAllResult<T> = T extends { withUsageCount: true }
? IAnnotationTagWithCountDb[]
: IAnnotationTagDb[];
@Service()
export class AnnotationTagService {
constructor(private tagRepository: AnnotationTagRepository) {}
toEntity(attrs: { name: string; id?: string }) {
attrs.name = attrs.name.trim();
return this.tagRepository.create(attrs);
}
async save(tag: AnnotationTagEntity) {
await validateEntity(tag);
return await this.tagRepository.save(tag, { transaction: false });
}
async delete(id: string) {
return await this.tagRepository.delete(id);
}
async getAll<T extends { withUsageCount: boolean }>(options?: T): Promise<GetAllResult<T>> {
if (options?.withUsageCount) {
const allTags = await this.tagRepository.find({
select: ['id', 'name', 'createdAt', 'updatedAt'],
relations: ['annotationMappings'],
});
return allTags.map(({ annotationMappings, ...rest }) => {
return {
...rest,
usageCount: annotationMappings.length,
} as IAnnotationTagWithCountDb;
}) as GetAllResult<T>;
}
const allTags = (await this.tagRepository.find({
select: ['id', 'name', 'createdAt', 'updatedAt'],
})) as IAnnotationTagDb[];
return allTags as GetAllResult<T>;
}
}

View file

@ -3,7 +3,7 @@ import { ExecutionService } from '@/executions/execution.service';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import Container from 'typedi'; import Container from 'typedi';
import { createWorkflow } from './shared/db/workflows'; import { createWorkflow } from './shared/db/workflows';
import { createExecution } from './shared/db/executions'; import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions';
import * as testDb from './shared/test-db'; import * as testDb from './shared/test-db';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { ExecutionSummaries } from '@/executions/execution.types'; import type { ExecutionSummaries } from '@/executions/execution.types';
@ -19,6 +19,8 @@ describe('ExecutionService', () => {
executionRepository = Container.get(ExecutionRepository); executionRepository = Container.get(ExecutionRepository);
executionService = new ExecutionService( executionService = new ExecutionService(
mock(),
mock(),
mock(), mock(),
mock(), mock(),
mock(), mock(),
@ -70,6 +72,10 @@ describe('ExecutionService', () => {
waitTill: null, waitTill: null,
retrySuccessId: null, retrySuccessId: null,
workflowName: expect.any(String), workflowName: expect.any(String),
annotation: {
tags: expect.arrayContaining([]),
vote: null,
},
}; };
expect(output.count).toBe(2); expect(output.count).toBe(2);
@ -462,4 +468,201 @@ describe('ExecutionService', () => {
expect(results[1].status).toBe('running'); expect(results[1].status).toBe('running');
}); });
}); });
describe('annotation', () => {
const summaryShape = {
id: expect.any(String),
workflowId: expect.any(String),
mode: expect.any(String),
retryOf: null,
status: expect.any(String),
startedAt: expect.any(String),
stoppedAt: expect.any(String),
waitTill: null,
retrySuccessId: null,
workflowName: expect.any(String),
};
afterEach(async () => {
await testDb.truncate(['AnnotationTag', 'ExecutionAnnotation']);
});
test('should add and retrieve annotation', async () => {
const workflow = await createWorkflow();
const execution1 = await createExecution({ status: 'success' }, workflow);
const execution2 = await createExecution({ status: 'success' }, workflow);
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
await annotateExecution(
execution1.id,
{ vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] },
[workflow.id],
);
await annotateExecution(execution2.id, { vote: 'down', tags: [annotationTags[2].id] }, [
workflow.id,
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
status: ['success'],
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(2);
expect(output.estimated).toBe(false);
expect(output.results).toEqual(
expect.arrayContaining([
{
...summaryShape,
annotation: {
tags: [expect.objectContaining({ name: 'tag3' })],
vote: 'down',
},
},
{
...summaryShape,
annotation: {
tags: [
expect.objectContaining({ name: 'tag1' }),
expect.objectContaining({ name: 'tag2' }),
],
vote: 'up',
},
},
]),
);
});
test('should update annotation', async () => {
const workflow = await createWorkflow();
const execution = await createExecution({ status: 'success' }, workflow);
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
await annotateExecution(execution.id, { vote: 'up', tags: [annotationTags[0].id] }, [
workflow.id,
]);
await annotateExecution(execution.id, { vote: 'down', tags: [annotationTags[1].id] }, [
workflow.id,
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
status: ['success'],
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(1);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([
{
...summaryShape,
annotation: {
tags: [expect.objectContaining({ name: 'tag2' })],
vote: 'down',
},
},
]);
});
test('should filter by annotation tags', async () => {
const workflow = await createWorkflow();
const executions = await Promise.all([
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
]);
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
await annotateExecution(
executions[0].id,
{ vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] },
[workflow.id],
);
await annotateExecution(executions[1].id, { vote: 'down', tags: [annotationTags[2].id] }, [
workflow.id,
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
status: ['success'],
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
annotationTags: [annotationTags[0].id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(1);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([
{
...summaryShape,
annotation: {
tags: [
expect.objectContaining({ name: 'tag1' }),
expect.objectContaining({ name: 'tag2' }),
],
vote: 'up',
},
},
]);
});
test('should filter by annotation vote', async () => {
const workflow = await createWorkflow();
const executions = await Promise.all([
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
]);
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
await annotateExecution(
executions[0].id,
{ vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] },
[workflow.id],
);
await annotateExecution(executions[1].id, { vote: 'down', tags: [annotationTags[2].id] }, [
workflow.id,
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
status: ['success'],
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
vote: 'up',
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(1);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([
{
...summaryShape,
annotation: {
tags: [
expect.objectContaining({ name: 'tag1' }),
expect.objectContaining({ name: 'tag2' }),
],
vote: 'up',
},
},
]);
});
});
}); });

View file

@ -13,7 +13,11 @@ import { Logger } from '@/logger';
import { mockInstance } from '../shared/mocking'; import { mockInstance } from '../shared/mocking';
import { createWorkflow } from './shared/db/workflows'; import { createWorkflow } from './shared/db/workflows';
import { createExecution, createSuccessfulExecution } from './shared/db/executions'; import {
annotateExecution,
createExecution,
createSuccessfulExecution,
} from './shared/db/executions';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
describe('softDeleteOnPruningCycle()', () => { describe('softDeleteOnPruningCycle()', () => {
@ -40,7 +44,7 @@ describe('softDeleteOnPruningCycle()', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await testDb.truncate(['Execution']); await testDb.truncate(['Execution', 'ExecutionAnnotation']);
}); });
afterAll(async () => { afterAll(async () => {
@ -138,6 +142,25 @@ describe('softDeleteOnPruningCycle()', () => {
expect.objectContaining({ id: executions[1].id, deletedAt: null }), expect.objectContaining({ id: executions[1].id, deletedAt: null }),
]); ]);
}); });
test('should not prune annotated executions', async () => {
const executions = [
await createSuccessfulExecution(workflow),
await createSuccessfulExecution(workflow),
await createSuccessfulExecution(workflow),
];
await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]);
await pruningService.softDeleteOnPruningCycle();
const result = await findAllExecutions();
expect(result).toEqual([
expect.objectContaining({ id: executions[0].id, deletedAt: null }),
expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }),
expect.objectContaining({ id: executions[2].id, deletedAt: null }),
]);
});
}); });
describe('when EXECUTIONS_DATA_MAX_AGE is set', () => { describe('when EXECUTIONS_DATA_MAX_AGE is set', () => {
@ -226,5 +249,33 @@ describe('softDeleteOnPruningCycle()', () => {
expect.objectContaining({ id: executions[1].id, deletedAt: null }), expect.objectContaining({ id: executions[1].id, deletedAt: null }),
]); ]);
}); });
test('should not prune annotated executions', async () => {
const executions = [
await createExecution(
{ finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' },
workflow,
),
await createExecution(
{ finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' },
workflow,
),
await createExecution(
{ finished: true, startedAt: now, stoppedAt: now, status: 'success' },
workflow,
),
];
await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]);
await pruningService.softDeleteOnPruningCycle();
const result = await findAllExecutions();
expect(result).toEqual([
expect.objectContaining({ id: executions[0].id, deletedAt: null }),
expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }),
expect.objectContaining({ id: executions[2].id, deletedAt: null }),
]);
});
}); });
}); });

View file

@ -5,6 +5,13 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository'; import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository';
import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository';
import { ExecutionService } from '@/executions/execution.service';
import type { AnnotationVote } from 'n8n-workflow';
import { mockInstance } from '@test/mocking';
import { Telemetry } from '@/telemetry';
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository';
mockInstance(Telemetry);
export async function createManyExecutions( export async function createManyExecutions(
amount: number, amount: number,
@ -85,6 +92,19 @@ export async function createWaitingExecution(workflow: WorkflowEntity) {
); );
} }
export async function annotateExecution(
executionId: string,
annotation: { vote?: AnnotationVote | null; tags?: string[] },
sharedWorkflowIds: string[],
) {
await Container.get(ExecutionService).annotate(executionId, annotation, sharedWorkflowIds);
}
export async function getAllExecutions() { export async function getAllExecutions() {
return await Container.get(ExecutionRepository).find(); return await Container.get(ExecutionRepository).find();
} }
export async function createAnnotationTags(annotationTags: string[]) {
const tagRepository = Container.get(AnnotationTagRepository);
return await tagRepository.save(annotationTags.map((name) => tagRepository.create({ name })));
}

View file

@ -48,11 +48,13 @@ export async function terminate() {
// Can't use `Object.keys(entities)` here because some entities have a `Entity` suffix, while the repositories don't // Can't use `Object.keys(entities)` here because some entities have a `Entity` suffix, while the repositories don't
const repositories = [ const repositories = [
'AnnotationTag',
'AuthIdentity', 'AuthIdentity',
'AuthProviderSyncHistory', 'AuthProviderSyncHistory',
'Credentials', 'Credentials',
'EventDestinations', 'EventDestinations',
'Execution', 'Execution',
'ExecutionAnnotation',
'ExecutionData', 'ExecutionData',
'ExecutionMetadata', 'ExecutionMetadata',
'InstalledNodes', 'InstalledNodes',

View file

@ -26,6 +26,7 @@ type EndpointGroup =
| 'eventBus' | 'eventBus'
| 'license' | 'license'
| 'variables' | 'variables'
| 'annotationTags'
| 'tags' | 'tags'
| 'externalSecrets' | 'externalSecrets'
| 'mfa' | 'mfa'

View file

@ -122,6 +122,10 @@ export const setupTestServer = ({
if (endpointGroups.length) { if (endpointGroups.length) {
for (const group of endpointGroups) { for (const group of endpointGroups) {
switch (group) { switch (group) {
case 'annotationTags':
await import('@/controllers/annotation-tags.controller');
break;
case 'credentials': case 'credentials':
await import('@/credentials/credentials.controller'); await import('@/credentials/credentials.controller');
break; break;

View file

@ -100,6 +100,7 @@ defineExpose({
focus, focus,
blur, blur,
focusOnInput, focusOnInput,
innerSelect,
}); });
</script> </script>

View file

@ -1,13 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
interface TagProps { interface TagProps {
text: string; text: string;
clickable?: boolean;
} }
defineOptions({ name: 'N8nTag' }); defineOptions({ name: 'N8nTag' });
defineProps<TagProps>(); withDefaults(defineProps<TagProps>(), {
clickable: true,
});
</script> </script>
<template> <template>
<span :class="['n8n-tag', $style.tag]" v-bind="$attrs"> <span :class="['n8n-tag', $style.tag, { [$style.clickable]: clickable }]" v-bind="$attrs">
{{ text }} {{ text }}
</span> </span>
</template> </template>
@ -20,11 +23,15 @@ defineProps<TagProps>();
background-color: var(--color-background-base); background-color: var(--color-background-base);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
cursor: pointer;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
&.clickable {
cursor: pointer;
&:hover { &:hover {
background-color: var(--color-background-medium); background-color: var(--color-background-medium);
} }
}
} }
</style> </style>

View file

@ -4,7 +4,7 @@ import N8nTag from '../N8nTag';
import N8nLink from '../N8nLink'; import N8nLink from '../N8nLink';
import { useI18n } from '../../composables/useI18n'; import { useI18n } from '../../composables/useI18n';
export interface ITag { interface ITag {
id: string; id: string;
name: string; name: string;
} }
@ -13,6 +13,7 @@ interface TagsProp {
tags?: ITag[]; tags?: ITag[];
truncate?: boolean; truncate?: boolean;
truncateAt?: number; truncateAt?: number;
clickable?: boolean;
} }
defineOptions({ name: 'N8nTags' }); defineOptions({ name: 'N8nTags' });
@ -20,6 +21,7 @@ const props = withDefaults(defineProps<TagsProp>(), {
tags: () => [], tags: () => [],
truncate: false, truncate: false,
truncateAt: 3, truncateAt: 3,
clickable: true,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -53,6 +55,7 @@ const onExpand = () => {
v-for="tag in visibleTags" v-for="tag in visibleTags"
:key="tag.id" :key="tag.id"
:text="tag.name" :text="tag.name"
:clickable="clickable"
@click="emit('click:tag', tag.id, $event)" @click="emit('click:tag', tag.id, $event)"
/> />
<N8nLink <N8nLink

View file

@ -49,6 +49,7 @@ import type {
INodeCredentialsDetails, INodeCredentialsDetails,
StartNodeData, StartNodeData,
IPersonalizationSurveyAnswersV4, IPersonalizationSurveyAnswersV4,
AnnotationVote,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { BulkCommand, Undoable } from '@/models/history'; import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
@ -1554,12 +1555,16 @@ export type ExecutionFilterMetadata = {
value: string; value: string;
}; };
export type ExecutionFilterVote = AnnotationVote | 'all';
export type ExecutionFilterType = { export type ExecutionFilterType = {
status: string; status: string;
workflowId: string; workflowId: string;
startDate: string | Date; startDate: string | Date;
endDate: string | Date; endDate: string | Date;
tags: string[]; tags: string[];
annotationTags: string[];
vote: ExecutionFilterVote;
metadata: ExecutionFilterMetadata[]; metadata: ExecutionFilterMetadata[];
}; };
@ -1571,6 +1576,8 @@ export type ExecutionsQueryFilter = {
metadata?: Array<{ key: string; value: string }>; metadata?: Array<{ key: string; value: string }>;
startedAfter?: string; startedAfter?: string;
startedBefore?: string; startedBefore?: string;
annotationTags?: string[];
vote?: ExecutionFilterVote;
}; };
export type SamlAttributeMapping = { export type SamlAttributeMapping = {

View file

@ -1,22 +1,32 @@
import type { IRestApiContext, ITag } from '@/Interface'; import type { IRestApiContext, ITag } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
export async function getTags(context: IRestApiContext, withUsageCount = false): Promise<ITag[]> { type TagsApiEndpoint = '/tags' | '/annotation-tags';
return await makeRestApiRequest(context, 'GET', '/tags', { withUsageCount });
export interface ITagsApi {
getTags: (context: IRestApiContext, withUsageCount?: boolean) => Promise<ITag[]>;
createTag: (context: IRestApiContext, params: { name: string }) => Promise<ITag>;
updateTag: (context: IRestApiContext, id: string, params: { name: string }) => Promise<ITag>;
deleteTag: (context: IRestApiContext, id: string) => Promise<boolean>;
} }
export async function createTag(context: IRestApiContext, params: { name: string }): Promise<ITag> { export function createTagsApi(endpoint: TagsApiEndpoint): ITagsApi {
return await makeRestApiRequest(context, 'POST', '/tags', params); return {
} getTags: async (context: IRestApiContext, withUsageCount = false): Promise<ITag[]> => {
return await makeRestApiRequest(context, 'GET', endpoint, { withUsageCount });
export async function updateTag( },
createTag: async (context: IRestApiContext, params: { name: string }): Promise<ITag> => {
return await makeRestApiRequest(context, 'POST', endpoint, params);
},
updateTag: async (
context: IRestApiContext, context: IRestApiContext,
id: string, id: string,
params: { name: string }, params: { name: string },
): Promise<ITag> { ): Promise<ITag> => {
return await makeRestApiRequest(context, 'PATCH', `/tags/${id}`, params); return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, params);
} },
deleteTag: async (context: IRestApiContext, id: string): Promise<boolean> => {
export async function deleteTag(context: IRestApiContext, id: string): Promise<boolean> { return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${id}`);
return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`); },
};
} }

View file

@ -0,0 +1,40 @@
<script lang="ts" setup>
import { computed, defineProps, defineEmits } from 'vue';
import TagsContainer from './TagsContainer.vue';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import type { ITag } from '@/Interface';
interface Props {
tagIds: string[];
limit?: number;
clickable?: boolean;
responsive?: boolean;
hoverable?: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
click: [tagId: string];
}>();
const annotationTagsStore = useAnnotationTagsStore();
const tagsById = computed<Record<string, ITag>>(() => annotationTagsStore.tagsById);
function onClick(tagId: string) {
emit('click', tagId);
}
</script>
<template>
<TagsContainer
:tag-ids="tagIds"
:tags-by-id="tagsById"
:limit="limit"
:clickable="clickable"
:responsive="responsive"
:hoverable="hoverable"
@click="onClick"
/>
</template>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { ANNOTATION_TAGS_MANAGER_MODAL_KEY } from '@/constants';
import type { EventBus } from 'n8n-design-system';
interface TagsDropdownWrapperProps {
placeholder?: string;
modelValue?: string[];
createEnabled?: boolean;
eventBus?: EventBus | null;
}
const props = withDefaults(defineProps<TagsDropdownWrapperProps>(), {
placeholder: '',
modelValue: () => [],
createEnabled: false,
eventBus: null,
});
const emit = defineEmits<{
'update:modelValue': [selected: string[]];
esc: [];
blur: [];
}>();
const tagsStore = useAnnotationTagsStore();
const uiStore = useUIStore();
const selectedTags = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const allTags = computed(() => tagsStore.allTags);
const isLoading = computed(() => tagsStore.isLoading);
const tagsById = computed(() => tagsStore.tagsById);
async function createTag(name: string) {
return await tagsStore.create(name);
}
function handleManageTags() {
uiStore.openModal(ANNOTATION_TAGS_MANAGER_MODAL_KEY);
}
function handleEsc() {
emit('esc');
}
function handleBlur() {
emit('blur');
}
// Fetch all tags when the component is created
void tagsStore.fetchAll();
</script>
<template>
<TagsDropdown
v-model="selectedTags"
:placeholder="placeholder"
:create-enabled="createEnabled"
:event-bus="eventBus"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
:create-tag="createTag"
@manage-tags="handleManageTags"
@esc="handleEsc"
@blur="handleBlur"
/>
</template>

View file

@ -3,7 +3,7 @@ import { defineComponent } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; import { MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import TagsDropdown from '@/components/TagsDropdown.vue'; import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import Modal from '@/components/Modal.vue'; import Modal from '@/components/Modal.vue';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
@ -16,7 +16,7 @@ import { useRouter } from 'vue-router';
export default defineComponent({ export default defineComponent({
name: 'DuplicateWorkflow', name: 'DuplicateWorkflow',
components: { TagsDropdown, Modal }, components: { WorkflowTagsDropdown, Modal },
props: ['modalName', 'isActive', 'data'], props: ['modalName', 'isActive', 'data'],
setup() { setup() {
const router = useRouter(); const router = useRouter();
@ -167,7 +167,7 @@ export default defineComponent({
:placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')" :placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')"
:maxlength="MAX_WORKFLOW_NAME_LENGTH" :maxlength="MAX_WORKFLOW_NAME_LENGTH"
/> />
<TagsDropdown <WorkflowTagsDropdown
v-if="settingsStore.areTagsEnabled" v-if="settingsStore.areTagsEnabled"
ref="dropdown" ref="dropdown"
v-model="currentTagIds" v-model="currentTagIds"

View file

@ -14,11 +14,11 @@ import {
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import ShortenName from '@/components/ShortenName.vue'; import ShortenName from '@/components/ShortenName.vue';
import TagsContainer from '@/components/TagsContainer.vue'; import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
import PushConnectionTracker from '@/components/PushConnectionTracker.vue'; import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue'; import WorkflowActivator from '@/components/WorkflowActivator.vue';
import SaveButton from '@/components/SaveButton.vue'; import SaveButton from '@/components/SaveButton.vue';
import TagsDropdown from '@/components/TagsDropdown.vue'; import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import InlineTextEdit from '@/components/InlineTextEdit.vue'; import InlineTextEdit from '@/components/InlineTextEdit.vue';
import BreakpointsObserver from '@/components/BreakpointsObserver.vue'; import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue'; import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
@ -631,7 +631,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
</BreakpointsObserver> </BreakpointsObserver>
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container"> <span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
<TagsDropdown <WorkflowTagsDropdown
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)" v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
ref="dropdown" ref="dropdown"
v-model="appliedTagIds" v-model="appliedTagIds"
@ -653,7 +653,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
+ {{ $locale.baseText('workflowDetails.addTag') }} + {{ $locale.baseText('workflowDetails.addTag') }}
</span> </span>
</div> </div>
<TagsContainer <WorkflowTagsContainer
v-else v-else
:key="workflow.id" :key="workflow.id"
:tag-ids="workflowTagIds" :tag-ids="workflowTagIds"

View file

@ -13,6 +13,7 @@ import {
INVITE_USER_MODAL_KEY, INVITE_USER_MODAL_KEY,
PERSONALIZATION_MODAL_KEY, PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY, NPS_SURVEY_MODAL_KEY,
NEW_ASSISTANT_SESSION_MODAL, NEW_ASSISTANT_SESSION_MODAL,
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
@ -46,7 +47,8 @@ import CredentialsSelectModal from '@/components/CredentialsSelectModal.vue';
import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue'; import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
import ModalRoot from '@/components/ModalRoot.vue'; import ModalRoot from '@/components/ModalRoot.vue';
import PersonalizationModal from '@/components/PersonalizationModal.vue'; import PersonalizationModal from '@/components/PersonalizationModal.vue';
import TagsManager from '@/components/TagsManager/TagsManager.vue'; import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue';
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.vue';
import UpdatesPanel from '@/components/UpdatesPanel.vue'; import UpdatesPanel from '@/components/UpdatesPanel.vue';
import NpsSurvey from '@/components/NpsSurvey.vue'; import NpsSurvey from '@/components/NpsSurvey.vue';
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue'; import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
@ -105,7 +107,11 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
</ModalRoot> </ModalRoot>
<ModalRoot :name="TAGS_MANAGER_MODAL_KEY"> <ModalRoot :name="TAGS_MANAGER_MODAL_KEY">
<TagsManager /> <WorkflowTagsManager />
</ModalRoot>
<ModalRoot :name="ANNOTATION_TAGS_MANAGER_MODAL_KEY">
<AnnotationTagsManager />
</ModalRoot> </ModalRoot>
<ModalRoot :name="VERSIONS_MODAL_KEY" :keep-alive="true"> <ModalRoot :name="VERSIONS_MODAL_KEY" :keep-alive="true">

View file

@ -1,83 +1,71 @@
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, type ComponentInstance } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import type { ComponentInstance } from 'vue';
import type { ITag } from '@/Interface'; import type { ITag } from '@/Interface';
import IntersectionObserver from './IntersectionObserver.vue'; import IntersectionObserver from './IntersectionObserver.vue';
import IntersectionObserved from './IntersectionObserved.vue'; import IntersectionObserved from './IntersectionObserved.vue';
import { mapStores } from 'pinia';
import { useTagsStore } from '@/stores/tags.store';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
// random upper limit if none is set to minimize performance impact of observers interface TagsContainerProps {
const DEFAULT_MAX_TAGS_LIMIT = 20; tagIds: string[];
tagsById: { [id: string]: ITag };
interface TagEl extends ITag { limit?: number;
hidden?: boolean; clickable?: boolean;
title?: string; responsive?: boolean;
isCount?: boolean; hoverable?: boolean;
} }
export default defineComponent({ const props = withDefaults(defineProps<TagsContainerProps>(), {
name: 'TagsContainer', limit: 20,
components: { IntersectionObserver, IntersectionObserved }, clickable: false,
props: { responsive: false,
tagIds: { hoverable: false,
type: Array as () => string[], });
required: true,
}, const emit = defineEmits<{
limit: { click: [tagId: string];
type: Number, }>();
default: DEFAULT_MAX_TAGS_LIMIT,
}, // Data
clickable: Boolean, const maxWidth = ref(320);
responsive: Boolean, const intersectionEventBus = createEventBus();
hoverable: Boolean, const visibility = ref<{ [id: string]: boolean }>({});
}, const tagsContainer = ref<ComponentInstance<typeof IntersectionObserver>>();
emits: {
click: null, // Computed
}, const style = computed(() => ({
data() { 'max-width': `${maxWidth.value}px`,
return { }));
maxWidth: 320,
intersectionEventBus: createEventBus(), const tags = computed(() => {
visibility: {} as { [id: string]: boolean }, const allTags = props.tagIds.map((tagId: string) => props.tagsById[tagId]).filter(Boolean);
debouncedSetMaxWidth: () => {},
}; let toDisplay: Array<ITag & { hidden?: boolean; title?: string; isCount?: boolean }> = props.limit
}, ? allTags.slice(0, props.limit)
computed: { : allTags;
...mapStores(useTagsStore),
style() {
return {
'max-width': `${this.maxWidth}px`,
};
},
tags() {
const tags = this.tagIds
.map((tagId: string) => this.tagsStore.tagsById[tagId])
.filter(Boolean); // if tag has been deleted from store
let toDisplay: TagEl[] = this.limit ? tags.slice(0, this.limit) : tags;
toDisplay = toDisplay.map((tag: ITag) => ({ toDisplay = toDisplay.map((tag: ITag) => ({
...tag, ...tag,
hidden: this.responsive && !this.visibility[tag.id], hidden: props.responsive && !visibility.value[tag.id],
})); }));
let visibleCount = toDisplay.length; let visibleCount = toDisplay.length;
if (this.responsive) { if (props.responsive) {
visibleCount = Object.values(this.visibility).reduce( visibleCount = Object.values(visibility.value).reduce(
(accu, val) => (val ? accu + 1 : accu), (accu, val) => (val ? accu + 1 : accu),
0, 0,
); );
} }
if (visibleCount < tags.length) { if (visibleCount < allTags.length) {
const hidden = tags.slice(visibleCount); const hidden = allTags.slice(visibleCount);
const hiddenTitle = hidden.reduce((accu: string, tag: ITag) => { const hiddenTitle = hidden.reduce(
return accu ? `${accu}, ${tag.name}` : tag.name; (accu: string, tag: ITag) => (accu ? `${accu}, ${tag.name}` : tag.name),
}, ''); '',
);
const countTag: TagEl = { const countTag = {
id: 'count', id: 'count',
name: `+${hidden.length}`, name: `+${hidden.length}`,
title: hiddenTitle, title: hiddenTitle,
@ -87,47 +75,47 @@ export default defineComponent({
} }
return toDisplay; return toDisplay;
}, });
},
created() { // Methods
this.debouncedSetMaxWidth = debounce(this.setMaxWidth, 100); const setMaxWidth = () => {
}, const container = tagsContainer.value?.$el as HTMLElement;
mounted() { const parent = container?.parentNode as HTMLElement;
this.setMaxWidth();
window.addEventListener('resize', this.debouncedSetMaxWidth);
},
beforeUnmount() {
window.removeEventListener('resize', this.debouncedSetMaxWidth);
},
methods: {
setMaxWidth() {
const containerEl = this.$refs.tagsContainer as ComponentInstance<IntersectionObserver>;
const container = containerEl.$el as HTMLElement;
const parent = container.parentNode as HTMLElement;
if (parent) { if (parent) {
this.maxWidth = 0; maxWidth.value = 0;
void this.$nextTick(() => { void nextTick(() => {
this.maxWidth = parent.clientWidth; maxWidth.value = parent.clientWidth;
}); });
} }
}, };
onObserved({ el, isIntersecting }: { el: HTMLElement; isIntersecting: boolean }) {
const debouncedSetMaxWidth = debounce(setMaxWidth, 100);
const onObserved = ({ el, isIntersecting }: { el: HTMLElement; isIntersecting: boolean }) => {
if (el.dataset.id) { if (el.dataset.id) {
this.visibility = { ...this.visibility, [el.dataset.id]: isIntersecting }; visibility.value = { ...visibility.value, [el.dataset.id]: isIntersecting };
} }
}, };
onClick(e: MouseEvent, tag: TagEl) {
if (this.clickable) { const onClick = (e: MouseEvent, tag: ITag & { hidden?: boolean }) => {
if (props.clickable) {
e.stopPropagation(); e.stopPropagation();
} }
// if tag is hidden or not displayed
if (!tag.hidden) { if (!tag.hidden) {
this.$emit('click', tag.id); emit('click', tag.id);
} }
}, };
},
// Lifecycle hooks
onMounted(() => {
setMaxWidth();
window.addEventListener('resize', debouncedSetMaxWidth);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', debouncedSetMaxWidth);
}); });
</script> </script>

View file

@ -1,102 +1,88 @@
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { ITag } from '@/Interface';
import { MAX_TAG_NAME_LENGTH, TAGS_MANAGER_MODAL_KEY } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useTagsStore } from '@/stores/tags.store';
import type { EventBus, N8nOption, N8nSelect } from 'n8n-design-system';
import type { PropType } from 'vue';
import { storeToRefs } from 'pinia';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import type { ITag } from '@/Interface';
import { MAX_TAG_NAME_LENGTH } from '@/constants';
import { N8nOption, N8nSelect } from 'n8n-design-system';
import type { EventBus } from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n';
import { v4 as uuid } from 'uuid';
import { useToast } from '@/composables/useToast';
type SelectRef = InstanceType<typeof N8nSelect>; interface TagsDropdownProps {
type TagRef = InstanceType<typeof N8nOption>; placeholder: string;
type CreateRef = InstanceType<typeof N8nOption>; modelValue: string[];
createTag: (name: string) => Promise<ITag>;
eventBus: EventBus | null;
allTags: ITag[];
isLoading: boolean;
tagsById: Record<string, ITag>;
}
const i18n = useI18n();
const { showError } = useToast();
const props = withDefaults(defineProps<TagsDropdownProps>(), {
placeholder: '',
modelValue: () => [],
eventBus: null,
});
const emit = defineEmits<{
'update:modelValue': [selected: string[]];
esc: [];
blur: [];
'manage-tags': [];
}>();
const MANAGE_KEY = '__manage'; const MANAGE_KEY = '__manage';
const CREATE_KEY = '__create'; const CREATE_KEY = '__create';
export default defineComponent({ const selectRef = ref<InstanceType<typeof N8nSelect>>();
name: 'TagsDropdown', const tagRefs = ref<Array<InstanceType<typeof N8nOption>>>();
props: { const createRef = ref<InstanceType<typeof N8nOption>>();
placeholder: {},
modelValue: {
type: Array as PropType<string[]>,
default: () => [],
},
eventBus: {
type: Object as PropType<EventBus>,
default: null,
},
},
emits: {
'update:modelValue': null,
esc: null,
blur: null,
},
setup(props, { emit }) {
const i18n = useI18n();
const { showError } = useToast();
const tagsStore = useTagsStore();
const uiStore = useUIStore();
const { isLoading } = storeToRefs(tagsStore); const filter = ref('');
const focused = ref(false);
const preventUpdate = ref(false);
const selectRef = ref<SelectRef | undefined>(); const container = ref<HTMLDivElement>();
const tagRefs = ref<TagRef[] | undefined>();
const createRef = ref<CreateRef | undefined>();
const tags = ref([]); const dropdownId = uuid();
const filter = ref('');
const focused = ref(false);
const preventUpdate = ref(false);
const container = ref<HTMLDivElement | undefined>(); const options = computed<ITag[]>(() => {
return props.allTags.filter((tag: ITag) => tag && tag.name.includes(filter.value));
});
const allTags = computed<ITag[]>(() => { const appliedTags = computed<string[]>(() => {
return tagsStore.allTags; return props.modelValue.filter((id: string) => props.tagsById[id]);
}); });
const options = computed<ITag[]>(() => { watch(
return allTags.value.filter((tag: ITag) => tag && tag.name.includes(filter.value)); () => props.allTags,
});
const appliedTags = computed<string[]>(() => {
return props.modelValue.filter((id: string) => tagsStore.tagsById[id]);
});
watch(
() => allTags.value,
() => { () => {
// keep applied tags in sync with store, for example in case tag is deleted from store
if (props.modelValue.length !== appliedTags.value.length) { if (props.modelValue.length !== appliedTags.value.length) {
emit('update:modelValue', appliedTags.value); emit('update:modelValue', appliedTags.value);
} }
}, },
); );
onMounted(() => {
const select = selectRef.value?.innerSelect;
onMounted(() => {
const select = selectRef.value?.$refs?.innerSelect as
| { $refs: { input: Element } }
| undefined;
if (select) { if (select) {
const input = select.$refs.input as Element | undefined; const input = select.$refs.input as Element | undefined;
if (input) { if (input) {
input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`); input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`);
input.addEventListener('keydown', (e: Event) => { input.addEventListener('keydown', (e: Event) => {
const keyboardEvent = e as KeyboardEvent; const keyboardEvent = e as KeyboardEvent;
// events don't bubble outside of select, so need to hook onto input
if (keyboardEvent.key === 'Escape') { if (keyboardEvent.key === 'Escape') {
emit('esc'); emit('esc');
} else if (keyboardEvent.key === 'Enter' && filter.value.length === 0) { } else if (keyboardEvent.key === 'Enter' && filter.value.length === 0) {
preventUpdate.value = true; preventUpdate.value = true;
emit('blur'); emit('blur');
if (typeof selectRef.value?.blur === 'function') { if (typeof selectRef.value?.blur === 'function') {
selectRef.value.blur(); selectRef.value.blur();
} }
@ -106,28 +92,26 @@ export default defineComponent({
} }
props.eventBus?.on('focus', onBusFocus); props.eventBus?.on('focus', onBusFocus);
});
void tagsStore.fetchAll(); onBeforeUnmount(() => {
});
onBeforeUnmount(() => {
props.eventBus?.off('focus', onBusFocus); props.eventBus?.off('focus', onBusFocus);
}); });
function onBusFocus() { function onBusFocus() {
focusOnInput(); focusOnInput();
focusFirstOption(); focusFirstOption();
} }
function filterOptions(value = '') { function filterOptions(value = '') {
filter.value = value; filter.value = value;
void nextTick(() => focusFirstOption()); void nextTick(() => focusFirstOption());
} }
async function onCreate() { async function onCreate() {
const name = filter.value; const name = filter.value;
try { try {
const newTag = await tagsStore.create(name); const newTag = await props.createTag(name);
emit('update:modelValue', [...props.modelValue, newTag.id]); emit('update:modelValue', [...props.modelValue, newTag.id]);
filter.value = ''; filter.value = '';
@ -138,15 +122,15 @@ export default defineComponent({
i18n.baseText('tagsDropdown.showError.message', { interpolate: { name } }), i18n.baseText('tagsDropdown.showError.message', { interpolate: { name } }),
); );
} }
} }
function onTagsUpdated(selected: string[]) { function onTagsUpdated(selected: string[]) {
const manage = selected.find((value) => value === MANAGE_KEY); const manage = selected.find((value) => value === MANAGE_KEY);
const create = selected.find((value) => value === CREATE_KEY); const create = selected.find((value) => value === CREATE_KEY);
if (manage) { if (manage) {
filter.value = ''; filter.value = '';
uiStore.openModal(TAGS_MANAGER_MODAL_KEY); emit('manage-tags');
emit('blur'); emit('blur');
} else if (create) { } else if (create) {
void onCreate(); void onCreate();
@ -158,9 +142,9 @@ export default defineComponent({
preventUpdate.value = false; preventUpdate.value = false;
}, 0); }, 0);
} }
} }
function focusFirstOption() { function focusFirstOption() {
// focus on create option // focus on create option
if (createRef.value?.$el) { if (createRef.value?.$el) {
createRef.value.$el.dispatchEvent(new Event('mouseenter')); createRef.value.$el.dispatchEvent(new Event('mouseenter'));
@ -169,68 +153,42 @@ export default defineComponent({
else if (tagRefs.value?.[0]?.$el) { else if (tagRefs.value?.[0]?.$el) {
tagRefs.value[0].$el.dispatchEvent(new Event('mouseenter')); tagRefs.value[0].$el.dispatchEvent(new Event('mouseenter'));
} }
} }
function focusOnInput() { function focusOnInput() {
if (selectRef.value) { if (selectRef.value) {
selectRef.value.focusOnInput(); selectRef.value.focusOnInput();
focused.value = true; focused.value = true;
} }
} }
function onVisibleChange(visible: boolean) { function onVisibleChange(visible: boolean) {
if (!visible) { if (!visible) {
filter.value = ''; filter.value = '';
focused.value = false; focused.value = false;
} else { } else {
focused.value = true; focused.value = true;
} }
} }
function onRemoveTag() { function onRemoveTag() {
void nextTick(() => { void nextTick(() => {
focusOnInput(); focusOnInput();
}); });
} }
onClickOutside( onClickOutside(
container, container,
() => { () => {
emit('blur'); emit('blur');
}, },
{ ignore: ['.tags-dropdown', '#tags-manager-modal'] }, { ignore: [`.tags-dropdown-${dropdownId}`, '#tags-manager-modal'] },
); );
return {
i18n,
tags,
filter,
focused,
preventUpdate,
selectRef,
tagRefs,
createRef,
allTags,
appliedTags,
options,
isLoading,
MANAGE_KEY,
CREATE_KEY,
onTagsUpdated,
onCreate,
filterOptions,
onVisibleChange,
onRemoveTag,
container,
...useToast(),
};
},
});
</script> </script>
<template> <template>
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop> <div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
<n8n-select <N8nSelect
ref="selectRef" ref="selectRef"
:teleported="true" :teleported="true"
:model-value="appliedTags" :model-value="appliedTags"
@ -241,13 +199,13 @@ export default defineComponent({
multiple multiple
:reserve-keyword="false" :reserve-keyword="false"
loading-text="..." loading-text="..."
popper-class="tags-dropdown" :popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId]"
data-test-id="tags-dropdown" data-test-id="tags-dropdown"
@update:model-value="onTagsUpdated" @update:model-value="onTagsUpdated"
@visible-change="onVisibleChange" @visible-change="onVisibleChange"
@remove-tag="onRemoveTag" @remove-tag="onRemoveTag"
> >
<n8n-option <N8nOption
v-if="options.length === 0 && filter" v-if="options.length === 0 && filter"
:key="CREATE_KEY" :key="CREATE_KEY"
ref="createRef" ref="createRef"
@ -258,17 +216,16 @@ export default defineComponent({
<span> <span>
{{ i18n.baseText('tagsDropdown.createTag', { interpolate: { filter } }) }} {{ i18n.baseText('tagsDropdown.createTag', { interpolate: { filter } }) }}
</span> </span>
</n8n-option> </N8nOption>
<n8n-option v-else-if="options.length === 0" value="message" disabled> <N8nOption v-else-if="options.length === 0" value="message" disabled>
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span> <span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="allTags.length > 0">{{ <span v-if="allTags.length > 0">{{
i18n.baseText('tagsDropdown.noMatchingTagsExist') i18n.baseText('tagsDropdown.noMatchingTagsExist')
}}</span> }}</span>
<span v-else-if="filter">{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span> <span v-else-if="filter">{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
</n8n-option> </N8nOption>
<!-- key is id+index for keyboard navigation to work well with filter --> <N8nOption
<n8n-option
v-for="(tag, i) in options" v-for="(tag, i) in options"
:key="tag.id + '_' + i" :key="tag.id + '_' + i"
ref="tagRefs" ref="tagRefs"
@ -278,11 +235,11 @@ export default defineComponent({
data-test-id="tag" data-test-id="tag"
/> />
<n8n-option :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags"> <N8nOption :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<font-awesome-icon icon="cog" /> <font-awesome-icon icon="cog" />
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span> <span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</div> </div>
</template> </template>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import TagsManager from './TagsManager.vue';
import type { ITag } from '@/Interface';
import { ANNOTATION_TAGS_MANAGER_MODAL_KEY } from '@/constants';
const i18n = useI18n();
const { showError, showMessage } = useToast();
const tagsStore = useAnnotationTagsStore();
const tags = computed(() => tagsStore.allTags);
const isLoading = computed(() => tagsStore.isLoading);
async function fetchTags() {
try {
await tagsStore.fetchAll({ force: true, withUsageCount: true });
} catch (error) {
showError(
error,
i18n.baseText('tagsManager.showError.onFetch.title'),
i18n.baseText('tagsManager.showError.onFetch.message'),
);
}
}
async function createTag(name: string): Promise<ITag> {
try {
return await tagsStore.create(name);
} catch (error) {
const escapedName = escape(name);
showError(
error,
i18n.baseText('tagsManager.showError.onCreate.title'),
i18n.baseText('tagsManager.showError.onCreate.message', {
interpolate: { escapedName },
}) + ':',
);
throw error;
}
}
async function updateTag(id: string, name: string): Promise<ITag> {
try {
const updatedTag = await tagsStore.rename({ id, name });
showMessage({
title: i18n.baseText('tagsManager.showMessage.onUpdate.title'),
type: 'success',
});
return updatedTag;
} catch (error) {
const escapedName = escape(name);
showError(
error,
i18n.baseText('tagsManager.showError.onUpdate.title'),
i18n.baseText('tagsManager.showError.onUpdate.message', {
interpolate: { escapedName },
}) + ':',
);
throw error;
}
}
async function deleteTag(id: string): Promise<boolean> {
try {
const deleted = await tagsStore.deleteTagById(id);
if (!deleted) {
throw new Error(i18n.baseText('tagsManager.couldNotDeleteTag'));
}
showMessage({
title: i18n.baseText('tagsManager.showMessage.onDelete.title'),
type: 'success',
});
return deleted;
} catch (error) {
const tag = tagsStore.tagsById[id];
const escapedName = escape(tag?.name || '');
showError(
error,
i18n.baseText('tagsManager.showError.onDelete.title'),
i18n.baseText('tagsManager.showError.onDelete.message', {
interpolate: { escapedName },
}) + ':',
);
throw error;
}
}
</script>
<template>
<TagsManager
title-locale-key="annotationTagsManager.manageTags"
usage-locale-key="annotationTagsView.inUse"
usage-column-title-locale-key="annotationTagsView.usage"
no-tags-title-locale-key="noAnnotationTagsView.title"
no-tags-description-locale-key="noAnnotationTagsView.description"
:modal-key="ANNOTATION_TAGS_MANAGER_MODAL_KEY"
:tags="tags"
:is-loading="isLoading"
:on-fetch-tags="fetchTags"
:on-create-tag="createTag"
:on-update-tag="updateTag"
:on-delete-tag="deleteTag"
/>
</template>

View file

@ -1,3 +1,17 @@
<script setup lang="ts">
import type { BaseTextKey } from '@/plugins/i18n';
type Props = {
titleLocaleKey: BaseTextKey;
descriptionLocaleKey: BaseTextKey;
};
withDefaults(defineProps<Props>(), {
titleLocaleKey: 'noTagsView.readyToOrganizeYourWorkflows',
descriptionLocaleKey: 'noTagsView.withWorkflowTagsYouReFree',
});
</script>
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<el-col class="notags" :span="16"> <el-col class="notags" :span="16">
@ -5,11 +19,11 @@
<div> <div>
<div class="mb-s"> <div class="mb-s">
<n8n-heading size="large"> <n8n-heading size="large">
{{ $locale.baseText('noTagsView.readyToOrganizeYourWorkflows') }} {{ $locale.baseText(titleLocaleKey) }}
</n8n-heading> </n8n-heading>
</div> </div>
<div class="description"> <div class="description">
{{ $locale.baseText('noTagsView.withWorkflowTagsYouReFree') }} {{ $locale.baseText(descriptionLocaleKey) }}
</div> </div>
</div> </div>
<n8n-button label="Create a tag" size="large" @click="$emit('enableCreate')" /> <n8n-button label="Create a tag" size="large" @click="$emit('enableCreate')" />

View file

@ -1,167 +1,154 @@
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue'; import { ref, computed, onMounted } from 'vue';
import type { ITag } from '@/Interface'; import type { ITag } from '@/Interface';
import { useToast } from '@/composables/useToast';
import TagsView from '@/components/TagsManager/TagsView/TagsView.vue'; import TagsView from '@/components/TagsManager/TagsView/TagsView.vue';
import NoTagsView from '@/components/TagsManager/NoTagsView.vue'; import NoTagsView from '@/components/TagsManager/NoTagsView.vue';
import Modal from '@/components/Modal.vue'; import Modal from '@/components/Modal.vue';
import { TAGS_MANAGER_MODAL_KEY } from '@/constants';
import { mapStores } from 'pinia';
import { useTagsStore } from '@/stores/tags.store';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables/useI18n';
import type { BaseTextKey } from '@/plugins/i18n';
export default defineComponent({ interface TagsManagerProps {
name: 'TagsManager', modalKey: string;
components: { usageLocaleKey: BaseTextKey;
TagsView, usageColumnTitleLocaleKey: BaseTextKey;
NoTagsView, titleLocaleKey: BaseTextKey;
Modal, noTagsTitleLocaleKey: BaseTextKey;
}, noTagsDescriptionLocaleKey: BaseTextKey;
setup() { tags: ITag[];
return { isLoading: boolean;
...useToast(), onFetchTags: () => Promise<void>;
}; onCreateTag: (name: string) => Promise<ITag>;
}, onUpdateTag: (id: string, name: string) => Promise<ITag>;
data() { onDeleteTag: (id: string) => Promise<boolean>;
const tagIds = useTagsStore().allTags.map((tag) => tag.id); }
return {
tagIds,
isCreating: false,
modalBus: createEventBus(),
TAGS_MANAGER_MODAL_KEY,
};
},
created() {
void this.tagsStore.fetchAll({ force: true, withUsageCount: true });
},
computed: {
...mapStores(useTagsStore),
isLoading(): boolean {
return this.tagsStore.isLoading;
},
tags(): ITag[] {
return this.tagIds.map((tagId: string) => this.tagsStore.tagsById[tagId]).filter(Boolean); // if tag is deleted from store
},
hasTags(): boolean {
return this.tags.length > 0;
},
},
methods: {
onEnableCreate() {
this.isCreating = true;
},
onDisableCreate() { const props = withDefaults(defineProps<TagsManagerProps>(), {
this.isCreating = false; titleLocaleKey: 'tagsManager.manageTags',
}, usageLocaleKey: 'tagsView.inUse',
usageColumnTitleLocaleKey: 'tagsTable.usage',
noTagsTitleLocaleKey: 'noTagsView.readyToOrganizeYourWorkflows',
noTagsDescriptionLocaleKey: 'noTagsView.withWorkflowTagsYouReFree',
});
async onCreate(name: string, cb: (tag: ITag | null, error?: Error) => void) { const emit = defineEmits<{
'update:tags': [tags: ITag[]];
}>();
const tagIds = ref(props.tags.map((tag) => tag.id));
const isCreating = ref(false);
const modalBus = createEventBus();
const tags = computed(() =>
tagIds.value
.map((tagId) => props.tags.find((tag) => tag.id === tagId))
.filter((tag): tag is ITag => Boolean(tag)),
);
const hasTags = computed(() => tags.value.length > 0);
const i18n = useI18n();
onMounted(() => {
void props.onFetchTags();
});
function onEnableCreate() {
isCreating.value = true;
}
function onDisableCreate() {
isCreating.value = false;
}
async function onCreate(name: string, createCallback: (tag: ITag | null, error?: Error) => void) {
try { try {
if (!name) { if (!name) {
throw new Error(this.$locale.baseText('tagsManager.tagNameCannotBeEmpty')); throw new Error(i18n.baseText('tagsManager.tagNameCannotBeEmpty'));
} }
const newTag = await this.tagsStore.create(name); const newTag = await props.onCreateTag(name);
this.tagIds = [newTag.id].concat(this.tagIds); tagIds.value = [newTag.id, ...tagIds.value];
cb(newTag); emit('update:tags', [...props.tags, newTag]);
createCallback(newTag);
} catch (error) { } catch (error) {
const escapedName = escape(name); // const escapedName = escape(name);
this.showError( // Implement showError function or emit an event for error handling
error, createCallback(null, error as Error);
this.$locale.baseText('tagsManager.showError.onCreate.title'),
this.$locale.baseText('tagsManager.showError.onCreate.message', {
interpolate: { escapedName },
}) + ':',
);
cb(null, error);
} }
}, }
async onUpdate(id: string, name: string, cb: (tag: boolean, error?: Error) => void) { async function onUpdate(
const tag = this.tagsStore.tagsById[id]; id: string,
name: string,
updateCallback: (success: boolean, error?: Error) => void,
) {
const tag = props.tags.find((t) => t.id === id);
if (!tag) {
updateCallback(false, new Error('Tag not found'));
return;
}
const oldName = tag.name; const oldName = tag.name;
try { try {
if (!name) { if (!name) {
throw new Error(this.$locale.baseText('tagsManager.tagNameCannotBeEmpty')); throw new Error(i18n.baseText('tagsManager.tagNameCannotBeEmpty'));
} }
if (name === oldName) { if (name === oldName) {
cb(true); updateCallback(true);
return; return;
} }
const updatedTag = await this.tagsStore.rename({ id, name }); const updatedTag = await props.onUpdateTag(id, name);
cb(!!updatedTag); emit(
'update:tags',
this.showMessage({ props.tags.map((t) => (t.id === id ? updatedTag : t)),
title: this.$locale.baseText('tagsManager.showMessage.onUpdate.title'),
type: 'success',
});
} catch (error) {
const escapedName = escape(oldName);
this.showError(
error,
this.$locale.baseText('tagsManager.showError.onUpdate.title'),
this.$locale.baseText('tagsManager.showError.onUpdate.message', {
interpolate: { escapedName },
}) + ':',
); );
cb(false, error); updateCallback(true);
} catch (error) {
updateCallback(false, error as Error);
} }
}, }
async onDelete(id: string, cb: (deleted: boolean, error?: Error) => void) { async function onDelete(id: string, deleteCallback: (deleted: boolean, error?: Error) => void) {
const tag = this.tagsStore.tagsById[id]; const tag = props.tags.find((t) => t.id === id);
const name = tag.name; if (!tag) {
deleteCallback(false, new Error('Tag not found'));
return;
}
try { try {
const deleted = await this.tagsStore.deleteTagById(id); const deleted = await props.onDeleteTag(id);
if (!deleted) { if (!deleted) {
throw new Error(this.$locale.baseText('tagsManager.couldNotDeleteTag')); throw new Error(i18n.baseText('tagsManager.couldNotDeleteTag'));
} }
this.tagIds = this.tagIds.filter((tagId: string) => tagId !== id); tagIds.value = tagIds.value.filter((tagId) => tagId !== id);
emit(
cb(deleted); 'update:tags',
props.tags.filter((t) => t.id !== id),
this.showMessage({
title: this.$locale.baseText('tagsManager.showMessage.onDelete.title'),
type: 'success',
});
} catch (error) {
const escapedName = escape(name);
this.showError(
error,
this.$locale.baseText('tagsManager.showError.onDelete.title'),
this.$locale.baseText('tagsManager.showError.onDelete.message', {
interpolate: { escapedName },
}) + ':',
); );
cb(false, error); deleteCallback(deleted);
} catch (error) {
deleteCallback(false, error as Error);
} }
}, }
onEnter() { function onEnter() {
if (this.isLoading) { if (props.isLoading) {
return; return;
} else if (!this.hasTags) { } else if (!hasTags.value) {
this.onEnableCreate(); onEnableCreate();
} else { } else {
this.modalBus.emit('close'); modalBus.emit('close');
} }
}, }
},
});
</script> </script>
<template> <template>
<Modal <Modal
id="tags-manager-modal" :title="i18n.baseText(titleLocaleKey)"
:title="$locale.baseText('tagsManager.manageTags')" :name="modalKey"
:name="TAGS_MANAGER_MODAL_KEY"
:event-bus="modalBus" :event-bus="modalBus"
min-width="620px" min-width="620px"
min-height="420px" min-height="420px"
@ -173,16 +160,23 @@ export default defineComponent({
v-if="hasTags || isCreating" v-if="hasTags || isCreating"
:is-loading="isLoading" :is-loading="isLoading"
:tags="tags" :tags="tags"
:usage-locale-key="usageLocaleKey"
:usage-column-title-locale-key="usageColumnTitleLocaleKey"
@create="onCreate" @create="onCreate"
@update="onUpdate" @update="onUpdate"
@delete="onDelete" @delete="onDelete"
@disable-create="onDisableCreate" @disable-create="onDisableCreate"
/> />
<NoTagsView v-else @enable-create="onEnableCreate" /> <NoTagsView
v-else
:title-locale-key="noTagsTitleLocaleKey"
:description-locale-key="noTagsDescriptionLocaleKey"
@enable-create="onEnableCreate"
/>
</el-row> </el-row>
</template> </template>
<template #footer="{ close }"> <template #footer="{ close }">
<n8n-button :label="$locale.baseText('tagsManager.done')" float="right" @click="close" /> <n8n-button :label="i18n.baseText('tagsManager.done')" float="right" @click="close" />
</template> </template>
</Modal> </Modal>
</template> </template>

View file

@ -2,8 +2,10 @@
import type { ElTable } from 'element-plus'; import type { ElTable } from 'element-plus';
import { MAX_TAG_NAME_LENGTH } from '@/constants'; import { MAX_TAG_NAME_LENGTH } from '@/constants';
import type { ITagRow } from '@/Interface'; import type { ITagRow } from '@/Interface';
import type { PropType } from 'vue';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import type { N8nInput } from 'n8n-design-system'; import type { N8nInput } from 'n8n-design-system';
import type { BaseTextKey } from '@/plugins/i18n';
type TableRef = InstanceType<typeof ElTable>; type TableRef = InstanceType<typeof ElTable>;
type N8nInputRef = InstanceType<typeof N8nInput>; type N8nInputRef = InstanceType<typeof N8nInput>;
@ -13,7 +15,28 @@ const DELETE_TRANSITION_TIMEOUT = 100;
export default defineComponent({ export default defineComponent({
name: 'TagsTable', name: 'TagsTable',
props: ['rows', 'isLoading', 'newName', 'isSaving'], props: {
rows: {
type: Array as () => ITagRow[],
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
newName: {
type: String,
required: true,
},
isSaving: {
type: Boolean,
required: true,
},
usageColumnTitleLocaleKey: {
type: String as PropType<BaseTextKey>,
default: 'tagsTable.usage',
},
},
data() { data() {
return { return {
maxLength: MAX_TAG_NAME_LENGTH, maxLength: MAX_TAG_NAME_LENGTH,
@ -139,7 +162,7 @@ export default defineComponent({
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="$locale.baseText('tagsTable.usage')" width="150"> <el-table-column :label="$locale.baseText(usageColumnTitleLocaleKey)" width="170">
<template #default="scope"> <template #default="scope">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<div <div

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import type { ITag, ITagRow } from '@/Interface'; import type { ITag, ITagRow } from '@/Interface';
@ -7,6 +8,7 @@ import TagsTable from '@/components/TagsManager/TagsView/TagsTable.vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useRBACStore } from '@/stores/rbac.store'; import { useRBACStore } from '@/stores/rbac.store';
import type { BaseTextKey } from '@/plugins/i18n';
const matches = (name: string, filter: string) => const matches = (name: string, filter: string) =>
name.toLowerCase().trim().includes(filter.toLowerCase().trim()); name.toLowerCase().trim().includes(filter.toLowerCase().trim());
@ -15,6 +17,14 @@ export default defineComponent({
name: 'TagsView', name: 'TagsView',
components: { TagsTableHeader, TagsTable }, components: { TagsTableHeader, TagsTable },
props: { props: {
usageColumnTitleLocaleKey: {
type: String as PropType<BaseTextKey>,
default: 'tagsTable.usage',
},
usageLocaleKey: {
type: String as PropType<BaseTextKey>,
default: 'tagsView.inUse',
},
tags: { tags: {
type: Array as () => ITag[], type: Array as () => ITag[],
required: true, required: true,
@ -49,7 +59,7 @@ export default defineComponent({
rows(): ITagRow[] { rows(): ITagRow[] {
const getUsage = (count: number | undefined) => const getUsage = (count: number | undefined) =>
count && count > 0 count && count > 0
? this.$locale.baseText('tagsView.inUse', { adjustToNumber: count }) ? this.$locale.baseText(this.usageLocaleKey, { adjustToNumber: count })
: this.$locale.baseText('tagsView.notBeingUsed'); : this.$locale.baseText('tagsView.notBeingUsed');
const disabled = this.isCreateEnabled || !!this.updateId || !!this.deleteId; const disabled = this.isCreateEnabled || !!this.updateId || !!this.deleteId;
@ -182,6 +192,7 @@ export default defineComponent({
:is-loading="isLoading" :is-loading="isLoading"
:is-saving="isSaving" :is-saving="isSaving"
:new-name="newName" :new-name="newName"
:usage-column-title-locale-key="usageColumnTitleLocaleKey"
data-test-id="tags-table" data-test-id="tags-table"
@new-name-change="onNewNameChange" @new-name-change="onNewNameChange"
@update-enable="onUpdateEnable" @update-enable="onUpdateEnable"

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useTagsStore } from '@/stores/tags.store';
import TagsManager from './TagsManager.vue';
import type { ITag } from '@/Interface';
import { TAGS_MANAGER_MODAL_KEY } from '@/constants';
const i18n = useI18n();
const { showError, showMessage } = useToast();
const tagsStore = useTagsStore();
const tags = computed(() => tagsStore.allTags);
const isLoading = computed(() => tagsStore.isLoading);
async function fetchTags() {
try {
await tagsStore.fetchAll({ force: true, withUsageCount: true });
} catch (error) {
showError(
error,
i18n.baseText('tagsManager.showError.onFetch.title'),
i18n.baseText('tagsManager.showError.onFetch.message'),
);
}
}
async function createTag(name: string): Promise<ITag> {
try {
return await tagsStore.create(name);
} catch (error) {
const escapedName = escape(name);
showError(
error,
i18n.baseText('tagsManager.showError.onCreate.title'),
i18n.baseText('tagsManager.showError.onCreate.message', {
interpolate: { escapedName },
}) + ':',
);
throw error;
}
}
async function updateTag(id: string, name: string): Promise<ITag> {
try {
const updatedTag = await tagsStore.rename({ id, name });
showMessage({
title: i18n.baseText('tagsManager.showMessage.onUpdate.title'),
type: 'success',
});
return updatedTag;
} catch (error) {
const escapedName = escape(name);
showError(
error,
i18n.baseText('tagsManager.showError.onUpdate.title'),
i18n.baseText('tagsManager.showError.onUpdate.message', {
interpolate: { escapedName },
}) + ':',
);
throw error;
}
}
async function deleteTag(id: string): Promise<boolean> {
try {
const deleted = await tagsStore.deleteTagById(id);
if (!deleted) {
throw new Error(i18n.baseText('tagsManager.couldNotDeleteTag'));
}
showMessage({
title: i18n.baseText('tagsManager.showMessage.onDelete.title'),
type: 'success',
});
return deleted;
} catch (error) {
const tag = tagsStore.tagsById[id];
const escapedName = escape(tag?.name || '');
showError(
error,
i18n.baseText('tagsManager.showError.onDelete.title'),
i18n.baseText('tagsManager.showError.onDelete.message', {
interpolate: { escapedName },
}) + ':',
);
throw error;
}
}
</script>
<template>
<TagsManager
:modal-key="TAGS_MANAGER_MODAL_KEY"
:tags="tags"
:is-loading="isLoading"
:on-fetch-tags="fetchTags"
:on-create-tag="createTag"
:on-update-tag="updateTag"
:on-delete-tag="deleteTag"
/>
</template>

View file

@ -0,0 +1,40 @@
<script lang="ts" setup>
import { computed } from 'vue';
import TagsContainer from './TagsContainer.vue';
import { useTagsStore } from '@/stores/tags.store';
import type { ITag } from '@/Interface';
interface Props {
tagIds: string[];
limit?: number;
clickable?: boolean;
responsive?: boolean;
hoverable?: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
click: [tagId: string];
}>();
const annotationTagsStore = useTagsStore();
const tagsById = computed<Record<string, ITag>>(() => annotationTagsStore.tagsById);
function onClick(tagId: string) {
emit('click', tagId);
}
</script>
<template>
<TagsContainer
:tag-ids="tagIds"
:tags-by-id="tagsById"
:limit="limit"
:clickable="clickable"
:responsive="responsive"
:hoverable="hoverable"
@click="onClick"
/>
</template>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { useTagsStore } from '@/stores/tags.store';
import { TAGS_MANAGER_MODAL_KEY } from '@/constants';
import type { EventBus } from 'n8n-design-system';
interface TagsDropdownWrapperProps {
placeholder?: string;
modelValue?: string[];
createEnabled?: boolean;
eventBus?: EventBus | null;
}
const props = withDefaults(defineProps<TagsDropdownWrapperProps>(), {
placeholder: '',
modelValue: () => [],
createEnabled: false,
eventBus: null,
});
const emit = defineEmits<{
'update:modelValue': [selected: string[]];
esc: [];
blur: [];
}>();
const tagsStore = useTagsStore();
const uiStore = useUIStore();
const selectedTags = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const allTags = computed(() => tagsStore.allTags);
const isLoading = computed(() => tagsStore.isLoading);
const tagsById = computed(() => tagsStore.tagsById);
async function createTag(name: string) {
return await tagsStore.create(name);
}
function handleManageTags() {
uiStore.openModal(TAGS_MANAGER_MODAL_KEY);
}
function handleEsc() {
emit('esc');
}
function handleBlur() {
emit('blur');
}
// Fetch all tags when the component is created
void tagsStore.fetchAll();
</script>
<template>
<TagsDropdown
v-model="selectedTags"
:placeholder="placeholder"
:create-enabled="createEnabled"
:event-bus="eventBus"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
:create-tag="createTag"
@manage-tags="handleManageTags"
@esc="handleEsc"
@blur="handleBlur"
/>
</template>

View file

@ -14,9 +14,11 @@ const defaultFilterState: ExecutionFilterType = {
status: 'all', status: 'all',
workflowId: 'all', workflowId: 'all',
tags: [], tags: [],
annotationTags: [],
startDate: '', startDate: '',
endDate: '', endDate: '',
metadata: [{ key: '', value: '' }], metadata: [{ key: '', value: '' }],
vote: 'all',
}; };
const workflowDataFactory = (): IWorkflowShortResponse => ({ const workflowDataFactory = (): IWorkflowShortResponse => ({

View file

@ -7,14 +7,15 @@ import type {
IWorkflowDb, IWorkflowDb,
} from '@/Interface'; } from '@/Interface';
import { i18n as locale } from '@/plugins/i18n'; import { i18n as locale } from '@/plugins/i18n';
import TagsDropdown from '@/components/TagsDropdown.vue';
import { getObjectKeys, isEmpty } from '@/utils/typesUtils'; import { getObjectKeys, isEmpty } from '@/utils/typesUtils';
import { EnterpriseEditionFeature } from '@/constants'; import { EnterpriseEditionFeature, EXECUTION_ANNOTATION_EXPERIMENT } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { usePostHog } from '@/stores/posthog.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import type { Placement } from '@floating-ui/core'; import type { Placement } from '@floating-ui/core';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
export type ExecutionFilterProps = { export type ExecutionFilterProps = {
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>; workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;
@ -26,6 +27,7 @@ const DATE_TIME_MASK = 'YYYY-MM-DD HH:mm';
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const posthogStore = usePostHog();
const { debounce } = useDebounce(); const { debounce } = useDebounce();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
@ -46,15 +48,22 @@ const isCustomDataFilterTracked = ref(false);
const isAdvancedExecutionFilterEnabled = computed( const isAdvancedExecutionFilterEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters], () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
); );
const isAnnotationFiltersEnabled = computed(
() =>
isAdvancedExecutionFilterEnabled.value &&
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
);
const showTags = computed(() => false); const showTags = computed(() => false);
const getDefaultFilter = (): ExecutionFilterType => ({ const getDefaultFilter = (): ExecutionFilterType => ({
status: 'all', status: 'all',
workflowId: 'all', workflowId: 'all',
tags: [], tags: [],
annotationTags: [],
startDate: '', startDate: '',
endDate: '', endDate: '',
metadata: [{ key: '', value: '' }], metadata: [{ key: '', value: '' }],
vote: 'all',
}); });
const filter = reactive(getDefaultFilter()); const filter = reactive(getDefaultFilter());
@ -90,27 +99,25 @@ const statuses = computed(() => [
{ id: 'waiting', name: locale.baseText('executionsList.waiting') }, { id: 'waiting', name: locale.baseText('executionsList.waiting') },
]); ]);
const voteFilterOptions = computed(() => [
{ id: 'all', name: locale.baseText('executionsFilter.annotation.rating.all') },
{ id: 'up', name: locale.baseText('executionsFilter.annotation.rating.good') },
{ id: 'down', name: locale.baseText('executionsFilter.annotation.rating.bad') },
]);
const countSelectedFilterProps = computed(() => { const countSelectedFilterProps = computed(() => {
let count = 0; const nonDefaultFilters = [
if (filter.status !== 'all') { filter.status !== 'all',
count++; filter.workflowId !== 'all' && props.workflows.length,
} !isEmpty(filter.tags),
if (filter.workflowId !== 'all' && props.workflows.length) { !isEmpty(filter.annotationTags),
count++; filter.vote !== 'all',
} !isEmpty(filter.metadata),
if (!isEmpty(filter.tags)) { !!filter.startDate,
count++; !!filter.endDate,
} ].filter(Boolean);
if (!isEmpty(filter.metadata)) {
count++; return nonDefaultFilters.length;
}
if (!!filter.startDate) {
count++;
}
if (!!filter.endDate) {
count++;
}
return count;
}); });
// vModel.metadata is a text input and needs a debounced emit to avoid too many requests // vModel.metadata is a text input and needs a debounced emit to avoid too many requests
@ -134,8 +141,11 @@ const onFilterMetaChange = (index: number, prop: keyof ExecutionFilterMetadata,
// Can't use v-model on TagsDropdown component and thus vModel.tags is useless // Can't use v-model on TagsDropdown component and thus vModel.tags is useless
// We just emit the updated filter // We just emit the updated filter
const onTagsChange = (tags: string[]) => { const onTagsChange = () => {
filter.tags = tags; emit('filterChanged', filter);
};
const onAnnotationTagsChange = () => {
emit('filterChanged', filter); emit('filterChanged', filter);
}; };
@ -194,10 +204,10 @@ onBeforeMount(() => {
</div> </div>
<div v-if="showTags" :class="$style.group"> <div v-if="showTags" :class="$style.group">
<label for="execution-filter-tags">{{ locale.baseText('workflows.filters.tags') }}</label> <label for="execution-filter-tags">{{ locale.baseText('workflows.filters.tags') }}</label>
<TagsDropdown <WorkflowTagsDropdown
id="execution-filter-tags" id="execution-filter-tags"
v-model="filter.tags"
:placeholder="locale.baseText('workflowOpen.filterWorkflows')" :placeholder="locale.baseText('workflowOpen.filterWorkflows')"
:model-value="filter.tags"
:create-enabled="false" :create-enabled="false"
data-test-id="executions-filter-tags-select" data-test-id="executions-filter-tags-select"
@update:model-value="onTagsChange" @update:model-value="onTagsChange"
@ -247,19 +257,43 @@ onBeforeMount(() => {
/> />
</div> </div>
</div> </div>
<div v-if="isAnnotationFiltersEnabled" :class="$style.group">
<label for="execution-filter-annotation-tags">{{
locale.baseText('executionsFilter.annotation.tags')
}}</label>
<AnnotationTagsDropdown
id="execution-filter-annotation-tags"
:placeholder="locale.baseText('workflowOpen.filterWorkflows')"
v-model="filter.annotationTags"
:create-enabled="false"
data-test-id="executions-filter-annotation-tags-select"
@update:model-value="onAnnotationTagsChange"
/>
</div>
<div v-if="isAnnotationFiltersEnabled" :class="$style.group">
<label for="execution-filter-annotation-vote">{{
locale.baseText('executionsFilter.annotation.rating')
}}</label>
<n8n-select
id="execution-filter-annotation-vote"
v-model="vModel.vote"
:placeholder="locale.baseText('executionsFilter.annotation.selectVoteFilter')"
filterable
data-test-id="executions-filter-annotation-vote-select"
:teleported="teleported"
>
<n8n-option
v-for="(item, idx) in voteFilterOptions"
:key="idx"
:label="item.name"
:value="item.id"
/>
</n8n-select>
</div>
<div :class="$style.group"> <div :class="$style.group">
<n8n-tooltip placement="right"> <n8n-tooltip placement="right">
<template #content> <template #content>
<i18n-t tag="span" keypath="executionsFilter.customData.docsTooltip"> <i18n-t tag="span" keypath="executionsFilter.customData.docsTooltip" />
<template #link>
<a
target="_blank"
href="https://docs.n8n.io/workflows/executions/custom-executions-data/"
>
{{ locale.baseText('executionsFilter.customData.docsTooltip.link') }}
</a>
</template>
</i18n-t>
</template> </template>
<span :class="$style.label"> <span :class="$style.label">
{{ locale.baseText('executionsFilter.savedData') }} {{ locale.baseText('executionsFilter.savedData') }}

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import type { AnnotationVote } from 'n8n-workflow';
defineProps<{
vote: AnnotationVote | null;
}>();
const emit = defineEmits<{
'vote-click': [vote: AnnotationVote];
}>();
const onVoteClick = (vote: AnnotationVote) => {
emit('vote-click', vote);
};
</script>
<template>
<div :class="$style.ratingIcon">
<n8n-icon-button
:class="{ [$style.up]: vote === 'up' }"
type="tertiary"
text
size="medium"
icon="thumbs-up"
@click="onVoteClick('up')"
/>
<n8n-icon-button
:class="{ [$style.down]: vote === 'down' }"
type="tertiary"
text
size="medium"
icon="thumbs-down"
@click="onVoteClick('down')"
/>
</div>
</template>
<style module lang="scss">
.ratingIcon {
display: flex;
flex-direction: row;
.up {
color: var(--color-success);
}
.down {
color: var(--color-danger);
}
}
</style>

View file

@ -0,0 +1,379 @@
<script lang="ts">
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import { useExecutionsStore } from '@/stores/executions.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
import { createEventBus } from 'n8n-design-system';
import VoteButtons from '@/components/executions/workflow/VoteButtons.vue';
import { useToast } from '@/composables/useToast';
const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
return true;
}
const set = new Set(prev);
return curr.reduce((acc, val) => acc || !set.has(val), false);
};
export default defineComponent({
name: 'WorkflowExecutionAnnotationSidebar',
components: {
VoteButtons,
AnnotationTagsDropdown,
},
props: {
execution: {
type: Object as PropType<ExecutionSummary>,
default: null,
},
loading: {
type: Boolean,
default: true,
},
},
computed: {
...mapStores(useExecutionsStore, useWorkflowsStore),
vote() {
return this.activeExecution?.annotation?.vote || null;
},
activeExecution() {
// FIXME: this is a temporary workaround to make TS happy. activeExecution may contain customData, but it is type-casted to ExecutionSummary after fetching from the backend
return this.executionsStore.activeExecution as ExecutionSummary & {
customData?: Record<string, string>;
};
},
tagIds() {
return this.activeExecution?.annotation?.tags.map((tag) => tag.id) ?? [];
},
tags() {
return this.activeExecution?.annotation?.tags;
},
},
setup() {
return {
...useToast(),
};
},
data() {
return {
tagsEventBus: createEventBus(),
isTagsEditEnabled: false,
appliedTagIds: [] as string[],
tagsSaving: false,
};
},
methods: {
async onVoteClick(vote: AnnotationVote) {
if (!this.activeExecution) {
return;
}
// If user clicked on the same vote, remove it
// so that vote buttons act as toggle buttons
const voteToSet = vote === this.vote ? null : vote;
try {
await this.executionsStore.annotateExecution(this.activeExecution.id, { vote: voteToSet });
} catch (e) {
this.showError(e, this.$locale.baseText('executionAnnotationView.vote.error'));
}
},
onTagsEditEnable() {
this.appliedTagIds = this.tagIds;
this.isTagsEditEnabled = true;
setTimeout(() => {
this.tagsEventBus.emit('focus');
}, 0);
},
async onTagsBlur() {
if (!this.activeExecution) {
return;
}
const current = (this.tagIds ?? []) as string[];
const tags = this.appliedTagIds;
if (!hasChanged(current, tags)) {
this.isTagsEditEnabled = false;
return;
}
if (this.tagsSaving) {
return;
}
this.tagsSaving = true;
try {
await this.executionsStore.annotateExecution(this.activeExecution.id, { tags });
} catch (e) {
this.showError(e, this.$locale.baseText('executionAnnotationView.tag.error'));
}
this.tagsSaving = false;
this.isTagsEditEnabled = false;
},
onTagsEditEsc() {
this.isTagsEditEnabled = false;
},
},
});
</script>
<template>
<div
ref="container"
:class="['execution-annotation-sidebar', $style.container]"
data-test-id="execution-annotation-sidebar"
>
<div :class="$style.section">
<div :class="$style.vote">
<div>{{ $locale.baseText('generic.rating') }}</div>
<VoteButtons :vote="vote" @vote-click="onVoteClick" />
</div>
<span class="tags" data-test-id="annotation-tags-container">
<AnnotationTagsDropdown
v-if="isTagsEditEnabled"
v-model="appliedTagIds"
ref="dropdown"
:create-enabled="true"
:event-bus="tagsEventBus"
:placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@blur="onTagsBlur"
@esc="onTagsEditEsc"
/>
<div v-else-if="tagIds.length === 0">
<span
class="add-tag add-tag-standalone clickable"
data-test-id="new-tag-link"
@click="onTagsEditEnable"
>
+ {{ $locale.baseText('executionAnnotationView.addTag') }}
</span>
</div>
<span
v-else
class="tags-container"
data-test-id="execution-annotation-tags"
@click="onTagsEditEnable"
>
<span v-for="tag in tags" :key="tag.id" class="clickable">
<el-tag :title="tag.name" type="info" size="small" :disable-transitions="true">
{{ tag.name }}
</el-tag>
</span>
<span class="add-tag-wrapper">
<n8n-button
class="add-tag"
:label="`+ ` + $locale.baseText('executionAnnotationView.addTag')"
type="secondary"
size="mini"
:outline="false"
:text="true"
@click="onTagsEditEnable"
/>
</span>
</span>
</span>
</div>
<div :class="$style.section">
<div :class="$style.heading">
<n8n-heading tag="h3" size="small" color="text-dark">
{{ $locale.baseText('generic.annotationData') }}
</n8n-heading>
</div>
<div
v-if="activeExecution?.customData && Object.keys(activeExecution?.customData).length > 0"
:class="$style.metadata"
>
<div
v-for="attr in Object.keys(activeExecution?.customData)"
v-bind:key="attr"
:class="$style.customDataEntry"
>
<n8n-text :class="$style.key" size="small" color="text-base">
{{ attr }}
</n8n-text>
<n8n-text :class="$style.value" size="small" color="text-base">
{{ activeExecution?.customData[attr] }}
</n8n-text>
</div>
</div>
<div v-else :class="$style.noResultsContainer" data-test-id="execution-list-empty">
<n8n-text color="text-base" size="small" align="center">
<span v-html="$locale.baseText('executionAnnotationView.data.notFound')" />
</n8n-text>
</div>
</div>
</div>
</template>
<style module lang="scss">
.container {
flex: 250px 0 0;
background-color: var(--color-background-xlight);
border-left: var(--border-base);
z-index: 1;
display: flex;
flex-direction: column;
overflow: auto;
}
.section {
padding: var(--spacing-l);
display: flex;
flex-direction: column;
&:not(:last-child) {
display: flex;
padding-bottom: var(--spacing-l);
border-bottom: var(--border-base);
}
}
.metadata {
padding-top: var(--spacing-s);
}
.heading {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-right: var(--spacing-l);
}
.controls {
padding: var(--spacing-s) 0 var(--spacing-xs);
display: flex;
align-items: center;
justify-content: space-between;
padding-right: var(--spacing-m);
button {
display: flex;
align-items: center;
}
}
.vote {
padding: 0 0 var(--spacing-xs);
font-size: var(--font-size-xs);
flex: 1;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.ratingIcon {
display: flex;
flex-direction: row;
.highlight {
color: var(--color-primary);
}
}
}
.customDataEntry {
display: flex;
flex-direction: column;
&:not(:first-of-type) {
margin-top: var(--spacing-s);
}
.key {
font-weight: bold;
}
}
.executionList {
flex: 1;
overflow: auto;
margin-bottom: var(--spacing-m);
background-color: var(--color-background-xlight) !important;
// Scrolling fader
&::before {
position: absolute;
display: block;
width: 270px;
height: 6px;
background: linear-gradient(to bottom, rgba(251, 251, 251, 1) 0%, rgba(251, 251, 251, 0) 100%);
z-index: 999;
}
// Lower first execution card so fader is not visible when not scrolled
& > div:first-child {
margin-top: 3px;
}
}
.infoAccordion {
position: absolute;
bottom: 0;
margin-left: calc(-1 * var(--spacing-l));
border-top: var(--border-base);
& > div {
width: 309px;
background-color: var(--color-background-light);
margin-top: 0 !important;
}
}
.noResultsContainer {
width: 100%;
margin-top: var(--spacing-s);
//text-align: center;
}
</style>
<style lang="scss" scoped>
.execution-annotation-sidebar {
:deep(.el-skeleton__item) {
height: 60px;
border-radius: 0;
}
}
.tags-container {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
margin-top: calc(var(--spacing-4xs) * -1); // Cancel out top margin of first tags row
* {
margin: var(--spacing-4xs) var(--spacing-4xs) 0 0;
}
}
.add-tag {
font-size: 12px;
color: $custom-font-very-light;
font-weight: 600;
white-space: nowrap;
&:hover {
color: $color-primary;
text-decoration: none;
}
}
.add-tag-standalone {
padding: 20px 0; // to be more clickable
}
.add-tag-wrapper {
margin-left: calc(var(--spacing-2xs) * -1); // Cancel out right margin of last tag
}
</style>

View file

@ -1,6 +1,8 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue'; import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
import { createPinia, setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { STORES } from '@/constants';
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => ({ useRoute: () => ({
@ -9,6 +11,24 @@ vi.mock('vue-router', () => ({
RouterLink: vi.fn(), RouterLink: vi.fn(),
})); }));
const initialState = {
[STORES.SETTINGS]: {
settings: {
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
},
license: {
environment: 'development',
},
deployment: {
type: 'default',
},
enterprise: {},
},
},
};
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, { const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
global: { global: {
stubs: { stubs: {
@ -26,7 +46,7 @@ const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
describe('WorkflowExecutionsCard', () => { describe('WorkflowExecutionsCard', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); setActivePinia(createTestingPinia({ initialState }));
}); });
test.each([ test.each([

View file

@ -2,13 +2,15 @@
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers'; import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { VIEWS } from '@/constants'; import { EnterpriseEditionFeature, EXECUTION_ANNOTATION_EXPERIMENT, VIEWS } from '@/constants';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue'; import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { PermissionsRecord } from '@/permissions'; import type { PermissionsRecord } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
const props = defineProps<{ const props = defineProps<{
execution: ExecutionSummary; execution: ExecutionSummary;
@ -27,6 +29,17 @@ const locale = useI18n();
const executionHelpers = useExecutionHelpers(); const executionHelpers = useExecutionHelpers();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const posthogStore = usePostHog();
const settingsStore = useSettingsStore();
const isAdvancedExecutionFilterEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
);
const isAnnotationEnabled = computed(
() =>
isAdvancedExecutionFilterEnabled.value &&
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
);
const currentWorkflow = computed(() => (route.params.name as string) || workflowsStore.workflowId); const currentWorkflow = computed(() => (route.params.name as string) || workflowsStore.workflowId);
const retryExecutionActions = computed(() => [ const retryExecutionActions = computed(() => [
@ -110,6 +123,21 @@ function onRetryMenuItemSelect(action: string): void {
{{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }} {{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
</N8nText> </N8nText>
</div> </div>
<div v-if="isAnnotationEnabled" :class="$style.annotation">
<div v-if="execution.annotation?.vote" :class="$style.ratingIcon">
<FontAwesomeIcon
v-if="execution.annotation.vote == 'up'"
:class="$style.up"
icon="thumbs-up"
/>
<FontAwesomeIcon v-else :class="$style.down" icon="thumbs-down" />
</div>
<N8nTags
v-if="executionUIDetails.tags.length > 0"
:tags="executionUIDetails.tags"
:clickable="false"
></N8nTags>
</div>
</div> </div>
<div :class="$style.icons"> <div :class="$style.icons">
<N8nActionDropdown <N8nActionDropdown
@ -221,6 +249,23 @@ function onRetryMenuItemSelect(action: string): void {
border-left: var(--spacing-4xs) var(--border-style-base) var(--execution-card-border-unknown); border-left: var(--spacing-4xs) var(--border-style-base) var(--execution-card-border-unknown);
} }
} }
.annotation {
display: flex;
flex-direction: row;
gap: var(--spacing-3xs);
align-items: center;
margin: var(--spacing-4xs) 0 0;
.ratingIcon {
.up {
color: var(--color-success);
}
.down {
color: var(--color-danger);
}
}
}
} }
.executionLink { .executionLink {
@ -269,6 +314,7 @@ function onRetryMenuItemSelect(action: string): void {
margin-left: var(--spacing-2xs); margin-left: var(--spacing-2xs);
} }
} }
.showGap { .showGap {
margin-bottom: var(--spacing-2xs); margin-bottom: var(--spacing-2xs);
.executionLink { .executionLink {

View file

@ -2,11 +2,18 @@
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router'; import { onBeforeRouteLeave, useRouter } from 'vue-router';
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue'; import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
import { MAIN_HEADER_TABS, VIEWS } from '@/constants'; import {
EnterpriseEditionFeature,
EXECUTION_ANNOTATION_EXPERIMENT,
MAIN_HEADER_TABS,
VIEWS,
} from '@/constants';
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface'; import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import type { ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import { getNodeViewTab } from '@/utils/canvasUtils'; import { getNodeViewTab } from '@/utils/canvasUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -36,6 +43,18 @@ const emit = defineEmits<{
const workflowHelpers = useWorkflowHelpers({ router: useRouter() }); const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
const router = useRouter(); const router = useRouter();
const posthogStore = usePostHog();
const settingsStore = useSettingsStore();
const isAdvancedExecutionFilterEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
);
const isAnnotationEnabled = computed(
() =>
isAdvancedExecutionFilterEnabled.value &&
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
);
const temporaryExecution = computed<ExecutionSummary | undefined>(() => const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
props.executions.find((execution) => execution.id === props.execution?.id) props.executions.find((execution) => execution.id === props.execution?.id)
? undefined ? undefined
@ -116,6 +135,10 @@ onBeforeRouteLeave(async (to, _, next) => {
@stop-execution="onStopExecution" @stop-execution="onStopExecution"
/> />
</div> </div>
<WorkflowExecutionAnnotationSidebar
v-if="isAnnotationEnabled && execution"
:execution="execution"
/>
</div> </div>
</template> </template>

View file

@ -8,6 +8,7 @@ export interface IExecutionUIData {
startTime: string; startTime: string;
runningTime: string; runningTime: string;
showTimestamp: boolean; showTimestamp: boolean;
tags: Array<{ id: string; name: string }>;
} }
export function useExecutionHelpers() { export function useExecutionHelpers() {
@ -20,6 +21,7 @@ export function useExecutionHelpers() {
label: 'Status unknown', label: 'Status unknown',
runningTime: '', runningTime: '',
showTimestamp: true, showTimestamp: true,
tags: execution.annotation?.tags ?? [],
}; };
if (execution.status === 'new') { if (execution.status === 'new') {

View file

@ -48,6 +48,7 @@ export const DELETE_USER_MODAL_KEY = 'deleteUser';
export const INVITE_USER_MODAL_KEY = 'inviteUser'; export const INVITE_USER_MODAL_KEY = 'inviteUser';
export const DUPLICATE_MODAL_KEY = 'duplicate'; export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager'; export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
export const VERSIONS_MODAL_KEY = 'versions'; export const VERSIONS_MODAL_KEY = 'versions';
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings'; export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat'; export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
@ -630,6 +631,7 @@ export const enum STORES {
NODE_TYPES = 'nodeTypes', NODE_TYPES = 'nodeTypes',
CREDENTIALS = 'credentials', CREDENTIALS = 'credentials',
TAGS = 'tags', TAGS = 'tags',
ANNOTATION_TAGS = 'annotationTags',
VERSIONS = 'versions', VERSIONS = 'versions',
NODE_CREATOR = 'nodeCreator', NODE_CREATOR = 'nodeCreator',
WEBHOOKS = 'webhooks', WEBHOOKS = 'webhooks',
@ -691,6 +693,8 @@ export const MORE_ONBOARDING_OPTIONS_EXPERIMENT = {
variant: 'variant', variant: 'variant',
}; };
export const EXECUTION_ANNOTATION_EXPERIMENT = '023_execution_annotation';
export const EXPERIMENTS_TO_TRACK = [ export const EXPERIMENTS_TO_TRACK = [
ASK_AI_EXPERIMENT.name, ASK_AI_EXPERIMENT.name,
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT, TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,

View file

@ -5,6 +5,7 @@ import type { Scope } from '@n8n/permissions';
describe('permissions', () => { describe('permissions', () => {
it('getResourcePermissions for empty scopes', () => { it('getResourcePermissions for empty scopes', () => {
expect(getResourcePermissions()).toEqual({ expect(getResourcePermissions()).toEqual({
annotationTag: {},
auditLogs: {}, auditLogs: {},
banner: {}, banner: {},
communityPackage: {}, communityPackage: {},
@ -58,6 +59,7 @@ describe('permissions', () => {
]; ];
const permissionRecord: PermissionsRecord = { const permissionRecord: PermissionsRecord = {
annotationTag: {},
auditLogs: {}, auditLogs: {},
banner: {}, banner: {},
communityPackage: {}, communityPackage: {},

View file

@ -28,6 +28,8 @@
"clientSecret": "Client Secret" "clientSecret": "Client Secret"
} }
}, },
"generic.annotations": "Annotations",
"generic.annotationData": "Highlighted data",
"generic.any": "Any", "generic.any": "Any",
"generic.cancel": "Cancel", "generic.cancel": "Cancel",
"generic.close": "Close", "generic.close": "Close",
@ -51,6 +53,7 @@
"generic.beta": "beta", "generic.beta": "beta",
"generic.yes": "Yes", "generic.yes": "Yes",
"generic.no": "No", "generic.no": "No",
"generic.rating": "Rating",
"generic.retry": "Retry", "generic.retry": "Retry",
"generic.error": "Something went wrong", "generic.error": "Something went wrong",
"generic.settings": "Settings", "generic.settings": "Settings",
@ -96,6 +99,9 @@
"activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.", "activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.",
"activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.", "activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.", "activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
"annotationTagsManager.manageTags": "Manage execution tags",
"annotationTagsView.usage": "Usage (all workflows)",
"annotationTagsView.inUse": "{count} execution | {count} executions",
"auth.changePassword": "Change password", "auth.changePassword": "Change password",
"auth.changePassword.currentPassword": "Current password", "auth.changePassword.currentPassword": "Current password",
"auth.changePassword.mfaCode": "Two-factor code", "auth.changePassword.mfaCode": "Two-factor code",
@ -759,12 +765,23 @@
"executionView.onPaste.title": "Cannot paste here", "executionView.onPaste.title": "Cannot paste here",
"executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow", "executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow",
"executionView.notFound.message": "Execution with id '{executionId}' could not be found!", "executionView.notFound.message": "Execution with id '{executionId}' could not be found!",
"executionAnnotationView.data.notFound": "Show important data from executions here by adding an <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.executiondata/\">execution data</a> node to your workflow",
"executionAnnotationView.vote.error": "Unable to save annotation vote",
"executionAnnotationView.tag.error": "Unable to save annotation tags",
"executionAnnotationView.addTag": "Add tag",
"executionAnnotationView.chooseOrCreateATag": "Choose or create a tag",
"executionsFilter.annotation.tags": "Execution tags",
"executionsFilter.annotation.rating": "Rating",
"executionsFilter.annotation.rating.all": "Any rating",
"executionsFilter.annotation.rating.good": "Good",
"executionsFilter.annotation.rating.bad": "Bad",
"executionsFilter.annotation.selectVoteFilter": "Select Rating",
"executionsFilter.selectStatus": "Select Status", "executionsFilter.selectStatus": "Select Status",
"executionsFilter.selectWorkflow": "Select Workflow", "executionsFilter.selectWorkflow": "Select Workflow",
"executionsFilter.start": "Execution start", "executionsFilter.start": "Execution start",
"executionsFilter.startDate": "Earliest", "executionsFilter.startDate": "Earliest",
"executionsFilter.endDate": "Latest", "executionsFilter.endDate": "Latest",
"executionsFilter.savedData": "Custom data (saved in execution)", "executionsFilter.savedData": "Highlighted data",
"executionsFilter.savedDataKey": "Key", "executionsFilter.savedDataKey": "Key",
"executionsFilter.savedDataKeyPlaceholder": "ID", "executionsFilter.savedDataKeyPlaceholder": "ID",
"executionsFilter.savedDataValue": "Value (exact match)", "executionsFilter.savedDataValue": "Value (exact match)",
@ -772,7 +789,7 @@
"executionsFilter.reset": "Reset all", "executionsFilter.reset": "Reset all",
"executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}", "executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}",
"executionsFilter.customData.inputTooltip.link": "View plans", "executionsFilter.customData.inputTooltip.link": "View plans",
"executionsFilter.customData.docsTooltip": "Filter executions by data that you have explicitly saved in them (by calling $execution.customData.set(key, value)). {link}", "executionsFilter.customData.docsTooltip": "Filter executions by data you have saved in them using an Execution Data node. {link}",
"executionsFilter.customData.docsTooltip.link": "More info", "executionsFilter.customData.docsTooltip.link": "More info",
"expressionEdit.anythingInside": "Anything inside ", "expressionEdit.anythingInside": "Anything inside ",
"expressionEdit.isJavaScript": " is JavaScript.", "expressionEdit.isJavaScript": " is JavaScript.",
@ -965,6 +982,8 @@
"ndv.httpRequest.credentialOnly.docsNotice": "Use the <a target=\"_blank\" href=\"{docsUrl}\">{nodeName} docs</a> to construct your request. We'll take care of the authentication part if you add a {nodeName} credential below.", "ndv.httpRequest.credentialOnly.docsNotice": "Use the <a target=\"_blank\" href=\"{docsUrl}\">{nodeName} docs</a> to construct your request. We'll take care of the authentication part if you add a {nodeName} credential below.",
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?", "noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows", "noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
"noAnnotationTagsView.title": "Organize your executions",
"noAnnotationTagsView.description": "Execution tags help you label and identify different classes of execution. Plus once you tag an execution, its never deleted",
"node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>", "node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
"node.activateDeactivateNode": "Activate/Deactivate Node", "node.activateDeactivateNode": "Activate/Deactivate Node",
"node.changeColor": "Change color", "node.changeColor": "Change color",
@ -1914,6 +1933,8 @@
"tagsManager.couldNotDeleteTag": "Could not delete tag", "tagsManager.couldNotDeleteTag": "Could not delete tag",
"tagsManager.done": "Done", "tagsManager.done": "Done",
"tagsManager.manageTags": "Manage tags", "tagsManager.manageTags": "Manage tags",
"tagsManager.showError.onFetch.title": "Could not fetch tags",
"tagsManager.showError.onFetch.message": "A problem occurred when trying to fetch tags",
"tagsManager.showError.onCreate.message": "A problem occurred when trying to create the tag '{escapedName}'", "tagsManager.showError.onCreate.message": "A problem occurred when trying to create the tag '{escapedName}'",
"tagsManager.showError.onCreate.title": "Could not create tag", "tagsManager.showError.onCreate.title": "Could not create tag",
"tagsManager.showError.onDelete.message": "A problem occurred when trying to delete the tag '{escapedName}'", "tagsManager.showError.onDelete.message": "A problem occurred when trying to delete the tag '{escapedName}'",

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { IDataObject, ExecutionSummary } from 'n8n-workflow'; import type { IDataObject, ExecutionSummary, AnnotationVote } from 'n8n-workflow';
import type { import type {
ExecutionFilterType, ExecutionFilterType,
ExecutionsQueryFilter, ExecutionsQueryFilter,
@ -82,9 +82,12 @@ export const useExecutionsStore = defineStore('executions', () => {
const allExecutions = computed(() => [...currentExecutions.value, ...executions.value]); const allExecutions = computed(() => [...currentExecutions.value, ...executions.value]);
function addExecution(execution: ExecutionSummaryWithScopes) { function addExecution(execution: ExecutionSummaryWithScopes) {
executionsById.value[execution.id] = { executionsById.value = {
...executionsById.value,
[execution.id]: {
...execution, ...execution,
mode: execution.mode, mode: execution.mode,
},
}; };
} }
@ -185,6 +188,24 @@ export const useExecutionsStore = defineStore('executions', () => {
} }
} }
async function annotateExecution(
id: string,
data: { tags?: string[]; vote?: AnnotationVote | null },
): Promise<void> {
const updatedExecution: ExecutionSummaryWithScopes = await makeRestApiRequest(
rootStore.restApiContext,
'PATCH',
`/executions/${id}`,
data,
);
addExecution(updatedExecution);
if (updatedExecution.id === activeExecution.value?.id) {
activeExecution.value = updatedExecution;
}
}
async function stopCurrentExecution(executionId: string): Promise<IExecutionsStopData> { async function stopCurrentExecution(executionId: string): Promise<IExecutionsStopData> {
return await makeRestApiRequest( return await makeRestApiRequest(
rootStore.restApiContext, rootStore.restApiContext,
@ -245,6 +266,7 @@ export const useExecutionsStore = defineStore('executions', () => {
return { return {
loading, loading,
annotateExecution,
executionsById, executionsById,
executions, executions,
executionsCount, executionsCount,

View file

@ -14,6 +14,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({ const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({
workflow: {}, workflow: {},
tag: {}, tag: {},
annotationTag: {},
user: {}, user: {},
credential: {}, credential: {},
variable: {}, variable: {},

View file

@ -1,4 +1,4 @@
import * as tagsApi from '@/api/tags'; import { createTagsApi } from '@/api/tags';
import { STORES } from '@/constants'; import { STORES } from '@/constants';
import type { ITag } from '@/Interface'; import type { ITag } from '@/Interface';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
@ -6,7 +6,17 @@ import { useRootStore } from './root.store';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useWorkflowsStore } from './workflows.store'; import { useWorkflowsStore } from './workflows.store';
export const useTagsStore = defineStore(STORES.TAGS, () => { const apiMapping = {
[STORES.TAGS]: createTagsApi('/tags'),
[STORES.ANNOTATION_TAGS]: createTagsApi('/annotation-tags'),
};
const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => {
const tagsApi = apiMapping[id];
return defineStore(
id,
() => {
const tagsById = ref<Record<string, ITag>>({}); const tagsById = ref<Record<string, ITag>>({});
const loading = ref(false); const loading = ref(false);
const fetchedAll = ref(false); const fetchedAll = ref(false);
@ -70,7 +80,10 @@ export const useTagsStore = defineStore(STORES.TAGS, () => {
} }
loading.value = true; loading.value = true;
const retrievedTags = await tagsApi.getTags(rootStore.restApiContext, Boolean(withUsageCount)); const retrievedTags = await tagsApi.getTags(
rootStore.restApiContext,
Boolean(withUsageCount),
);
setAllTags(retrievedTags); setAllTags(retrievedTags);
loading.value = false; loading.value = false;
return retrievedTags; return retrievedTags;
@ -111,4 +124,11 @@ export const useTagsStore = defineStore(STORES.TAGS, () => {
upsertTags, upsertTags,
deleteTag, deleteTag,
}; };
}); },
{},
);
};
export const useTagsStore = createTagsStore(STORES.TAGS);
export const useAnnotationTagsStore = createTagsStore(STORES.ANNOTATION_TAGS);

View file

@ -19,6 +19,7 @@ import {
PERSONALIZATION_MODAL_KEY, PERSONALIZATION_MODAL_KEY,
STORES, STORES,
TAGS_MANAGER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY, NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
VIEWS, VIEWS,
@ -108,6 +109,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
PERSONALIZATION_MODAL_KEY, PERSONALIZATION_MODAL_KEY,
INVITE_USER_MODAL_KEY, INVITE_USER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY, NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
WORKFLOW_LM_CHAT_MODAL_KEY, WORKFLOW_LM_CHAT_MODAL_KEY,

View file

@ -9,7 +9,9 @@ export function getDefaultExecutionFilters(): ExecutionFilterType {
startDate: '', startDate: '',
endDate: '', endDate: '',
tags: [], tags: [],
annotationTags: [],
metadata: [], metadata: [],
vote: 'all',
}; };
} }
@ -25,6 +27,14 @@ export const executionFilterToQueryFilter = (
queryFilter.tags = filter.tags; queryFilter.tags = filter.tags;
} }
if (!isEmpty(filter.annotationTags)) {
queryFilter.annotationTags = filter.annotationTags;
}
if (filter.vote !== 'all') {
queryFilter.vote = filter.vote;
}
if (!isEmpty(filter.metadata)) { if (!isEmpty(filter.metadata)) {
queryFilter.metadata = filter.metadata; queryFilter.metadata = filter.metadata;
} }
@ -54,6 +64,7 @@ export const executionFilterToQueryFilter = (
queryFilter.status = ['canceled']; queryFilter.status = ['canceled'];
break; break;
} }
return queryFilter; return queryFilter;
}; };

View file

@ -2,9 +2,9 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import ResourcesListLayout, { type IResource } from '@/components/layouts/ResourcesListLayout.vue'; import ResourcesListLayout, { type IResource } from '@/components/layouts/ResourcesListLayout.vue';
import WorkflowCard from '@/components/WorkflowCard.vue'; import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants'; import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
import type { ITag, IUser, IWorkflowDb } from '@/Interface'; import type { ITag, IUser, IWorkflowDb } from '@/Interface';
import TagsDropdown from '@/components/TagsDropdown.vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -36,7 +36,7 @@ const WorkflowsView = defineComponent({
components: { components: {
ResourcesListLayout, ResourcesListLayout,
WorkflowCard, WorkflowCard,
TagsDropdown, WorkflowTagsDropdown,
ProjectTabs, ProjectTabs,
}, },
data() { data() {
@ -432,7 +432,7 @@ export default WorkflowsView;
color="text-base" color="text-base"
class="mb-3xs" class="mb-3xs"
/> />
<TagsDropdown <WorkflowTagsDropdown
:placeholder="$locale.baseText('workflowOpen.filterWorkflows')" :placeholder="$locale.baseText('workflowOpen.filterWorkflows')"
:model-value="filters.tags" :model-value="filters.tags"
:create-enabled="false" :create-enabled="false"

View file

@ -25,7 +25,7 @@ export class ExecutionData implements INodeType {
properties: [ properties: [
{ {
displayName: displayName:
"Use this node to save fields you want to use later to easily find an execution (e.g. a user ID). You'll be able to search by this data in the 'executions' tab.<br>This feature is available on our Pro and Enterprise plans. <a href='https://n8n.io/pricing/' target='_blank'>More Info</a>.", "Save important data using this node. It will be displayed on each execution for easy reference and you can filter by it.<br />Filtering is available on Pro and Enterprise plans. <a href='https://n8n.io/pricing/' target='_blank'>More Info</a>",
name: 'notice', name: 'notice',
type: 'notice', type: 'notice',
default: '', default: '',
@ -38,9 +38,9 @@ export class ExecutionData implements INodeType {
noDataExpression: true, noDataExpression: true,
options: [ options: [
{ {
name: 'Save Execution Data for Search', name: 'Save Highlight Data (for Search/review)',
value: 'save', value: 'save',
action: 'Save execution data for search', action: 'Save Highlight Data (for search/review)',
}, },
], ],
}, },

View file

@ -2435,6 +2435,8 @@ export interface NodeExecutionWithMetadata extends INodeExecutionData {
pairedItem: IPairedItemData | IPairedItemData[]; pairedItem: IPairedItemData | IPairedItemData[];
} }
export type AnnotationVote = 'up' | 'down';
export interface ExecutionSummary { export interface ExecutionSummary {
id: string; id: string;
finished?: boolean; finished?: boolean;
@ -2452,6 +2454,13 @@ export interface ExecutionSummary {
nodeExecutionStatus?: { nodeExecutionStatus?: {
[key: string]: IExecutionSummaryNodeExecutionResult; [key: string]: IExecutionSummaryNodeExecutionResult;
}; };
annotation?: {
vote: AnnotationVote;
tags: Array<{
id: string;
name: string;
}>;
};
} }
export interface IExecutionSummaryNodeExecutionResult { export interface IExecutionSummaryNodeExecutionResult {