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);
// Make some failed executions by enabling Code node with syntax error
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 0);
executionsTab.actions.createManualExecutions(2);
// Then add some more successful ones
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 1);
executionsTab.actions.createManualExecutions(4);
};

View file

@ -582,7 +582,13 @@ describe('NDV', () => {
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).click();
ndv.getters
.outputDisplayMode()
.find('label')
.eq(1)
.scrollIntoView()
.should('be.visible')
.click();
ndv.getters.outputDataContainer().find('.json-data').should('exist');
ndv.getters

View file

@ -1,5 +1,6 @@
export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const;
export const RESOURCES = {
annotationTag: [...DEFAULT_OPERATIONS] as const,
auditLogs: ['manage'] as const,
banner: ['dismiss'] 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 AnnotationTagScope = ResourceScope<'annotationTag'>;
export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>;
export type BannerScope = ResourceScope<'banner', 'dismiss'>;
export type CommunityPackageScope = ResourceScope<
@ -44,6 +45,7 @@ export type WorkflowScope = ResourceScope<
>;
export type Scope =
| AnnotationTagScope
| AuditLogsScope
| BannerScope
| 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),
migrations: sqliteMigrations,
};
if (sqliteConfig.poolSize > 0) {
return {
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 { ExecutionMetadata } from './execution-metadata';
import { WorkflowEntity } from './workflow-entity';
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
@Entity()
@Index(['workflowId', 'id'])
@ -65,6 +66,9 @@ export class ExecutionEntity {
@OneToOne('ExecutionData', 'execution')
executionData: Relation<ExecutionData>;
@OneToOne('ExecutionAnnotation', 'execution')
annotation?: Relation<ExecutionAnnotation>;
@ManyToOne('WorkflowEntity')
workflow: WorkflowEntity;
}

View file

@ -22,13 +22,19 @@ import { WorkflowHistory } from './workflow-history';
import { Project } from './project';
import { ProjectRelation } from './project-relation';
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 = {
AnnotationTagEntity,
AnnotationTagMapping,
AuthIdentity,
AuthProviderSyncHistory,
AuthUser,
CredentialsEntity,
EventDestinations,
ExecutionAnnotation,
ExecutionEntity,
InstalledNodes,
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 { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -125,4 +126,5 @@ export const mysqlMigrations: Migration[] = [
AddConstraintToExecutionMetadata1720101653148,
CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828,
];

View file

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

View file

@ -58,6 +58,7 @@ import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActiv
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -119,6 +120,7 @@ const sqliteMigrations: Migration[] = [
AddConstraintToExecutionMetadata1720101653148,
CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828,
];
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 pick from 'lodash/pick';
import {
Brackets,
DataSource,
@ -21,14 +22,18 @@ import type {
} from '@n8n/typeorm';
import { parse, stringify } from 'flatted';
import { GlobalConfig } from '@n8n/config';
import {
ApplicationError,
type ExecutionStatus,
type ExecutionSummary,
type IRunExecutionData,
} from 'n8n-workflow';
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 {
ExecutionPayload,
@ -46,6 +51,9 @@ import { Logger } from '@/logger';
import type { ExecutionSummaries } from '@/executions/execution.types';
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
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 {
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(
id: string,
options?: {
includeData: true;
includeAnnotation?: boolean;
unflattenData: true;
where?: FindOptionsWhere<ExecutionEntity>;
},
@ -213,6 +233,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
id: string,
options?: {
includeData: true;
includeAnnotation?: boolean;
unflattenData?: false | undefined;
where?: FindOptionsWhere<ExecutionEntity>;
},
@ -221,6 +242,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
id: string,
options?: {
includeData?: boolean;
includeAnnotation?: boolean;
unflattenData?: boolean;
where?: FindOptionsWhere<ExecutionEntity>;
},
@ -229,6 +251,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
id: string,
options?: {
includeData?: boolean;
includeAnnotation?: boolean;
unflattenData?: boolean;
where?: FindOptionsWhere<ExecutionEntity>;
},
@ -240,7 +263,16 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
},
};
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);
@ -249,25 +281,21 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
return undefined;
}
const { executionData, metadata, ...rest } = execution;
const { executionData, metadata, annotation, ...rest } = execution;
const serializedAnnotation = this.serializeAnnotation(annotation);
if (options?.includeData && options?.unflattenData) {
return {
...rest,
data: parse(execution.executionData.data) as IRunExecutionData,
workflowData: execution.executionData.workflowData,
return {
...rest,
...(options?.includeData && {
data: options?.unflattenData
? (parse(executionData.data) as IRunExecutionData)
: executionData.data,
workflowData: executionData?.workflowData,
customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])),
} as IExecutionResponse;
} else if (options?.includeData) {
return {
...rest,
data: execution.executionData.data,
workflowData: execution.executionData.workflowData,
customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])),
} as IExecutionFlattedDb;
}
return rest;
}),
...(options?.includeAnnotation &&
serializedAnnotation && { annotation: serializedAnnotation }),
};
}
/**
@ -410,6 +438,13 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h
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
const date = new Date();
date.setHours(date.getHours() - maxAge);
@ -420,12 +455,13 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
];
if (maxCount > 0) {
const executions = await this.find({
select: ['id'],
skip: maxCount,
take: 1,
order: { id: 'DESC' },
});
const executions = await this.createQueryBuilder('execution')
.select('execution.id')
.where('execution.id NOT IN ' + annotatedExecutionsSubQuery.getQuery())
.skip(maxCount)
.take(1)
.orderBy('execution.id', 'DESC')
.getMany();
if (executions[0]) {
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
status: Not(In(['new', 'running', 'waiting'])),
})
// Only mark executions as deleted if they are not annotated
.andWhere('id NOT IN ' + annotatedExecutionsSubQuery.getQuery())
.andWhere(
new Brackets((qb) =>
countBasedWhere
@ -612,6 +650,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
},
includeData: true,
unflattenData: true,
includeAnnotation: true,
});
}
@ -622,6 +661,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
},
includeData: true,
unflattenData: false,
includeAnnotation: true,
});
}
@ -683,12 +723,80 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
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[]> {
if (query?.accessibleWorkflowIds?.length === 0) {
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));
}
@ -764,6 +872,8 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
startedBefore,
startedAfter,
metadata,
annotationTags,
vote,
} = query;
const fields = Object.keys(this.summaryFields)
@ -812,9 +922,62 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
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;
}
/**
* 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() {
const executions = await this.find({ select: ['id'], order: { id: 'ASC' } });

View file

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

View file

@ -2,12 +2,12 @@ import { Container, Service } from 'typedi';
import { GlobalConfig } from '@n8n/config';
import { validate as jsonSchemaValidate } from 'jsonschema';
import type {
IWorkflowBase,
ExecutionError,
ExecutionStatus,
INode,
IRunExecutionData,
IWorkflowBase,
WorkflowExecuteMode,
ExecutionStatus,
} from 'n8n-workflow';
import {
ApplicationError,
@ -41,6 +41,8 @@ import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.err
import { License } from '@/license';
import type { User } from '@/databases/entities/user';
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 = {
$id: '/IGetExecutionsQueryFilter',
@ -60,6 +62,8 @@ export const schemaGetExecutionsQueryFilter = {
metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } },
startedAfter: { type: 'date-time' },
startedBefore: { type: 'date-time' },
annotationTags: { type: 'array', items: { type: 'string' } },
vote: { type: 'string' },
},
$defs: {
metadata: {
@ -85,6 +89,8 @@ export class ExecutionService {
private readonly globalConfig: GlobalConfig,
private readonly logger: Logger,
private readonly activeExecutions: ActiveExecutions,
private readonly executionAnnotationRepository: ExecutionAnnotationRepository,
private readonly annotationTagMappingRepository: AnnotationTagMappingRepository,
private readonly executionRepository: ExecutionRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly nodeTypes: NodeTypes,
@ -96,7 +102,7 @@ export class ExecutionService {
) {}
async findOne(
req: ExecutionRequest.GetOne,
req: ExecutionRequest.GetOne | ExecutionRequest.Update,
sharedWorkflowIds: string[],
): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> {
if (!sharedWorkflowIds.length) return undefined;
@ -495,4 +501,42 @@ export class ExecutionService {
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 { Scope } from '@n8n/permissions';
import type {
AnnotationVote,
ExecutionStatus,
ExecutionSummary,
IDataObject,
@ -34,6 +35,11 @@ export declare namespace ExecutionRequest {
};
}
type ExecutionUpdatePayload = {
tags?: string[];
vote?: AnnotationVote | null;
};
type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & {
rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params
};
@ -45,6 +51,8 @@ export declare namespace ExecutionRequest {
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>;
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;
type Update = AuthenticatedRequest<RouteParams.ExecutionId, {}, ExecutionUpdatePayload, {}>;
}
export namespace ExecutionSummaries {
@ -69,6 +77,8 @@ export namespace ExecutionSummaries {
metadata: Array<{ key: string; value: string }>;
startedAfter: string;
startedBefore: string;
annotationTags: string[]; // tag IDs
vote: AnnotationVote;
}>;
type AccessFields = {

View file

@ -1,6 +1,7 @@
import { ExecutionRequest, type ExecutionSummaries } from './execution.types';
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 { License } from '@/license';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
@ -47,7 +48,10 @@ export class ExecutionsController {
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 noRange = !query.range.lastId || !query.range.firstId;
@ -110,4 +114,23 @@ export class ExecutionsController {
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 type { WorkflowEntity } from '@/databases/entities/workflow-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 { User } from '@/databases/entities/user';
import type {
@ -16,6 +17,7 @@ export async function validateEntity(
| WorkflowEntity
| CredentialsEntity
| TagEntity
| AnnotationTagEntity
| User
| UserUpdatePayload
| UserRoleChangePayload

View file

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

View file

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

View file

@ -448,6 +448,17 @@ export declare namespace TagsRequest {
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
// ----------------------------------

View file

@ -38,6 +38,7 @@ import { OrchestrationService } from '@/services/orchestration.service';
import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay';
import '@/controllers/active-workflows.controller';
import '@/controllers/annotation-tags.controller';
import '@/controllers/auth.controller';
import '@/controllers/binary-data.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 Container from 'typedi';
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 { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { ExecutionSummaries } from '@/executions/execution.types';
@ -19,6 +19,8 @@ describe('ExecutionService', () => {
executionRepository = Container.get(ExecutionRepository);
executionService = new ExecutionService(
mock(),
mock(),
mock(),
mock(),
mock(),
@ -70,6 +72,10 @@ describe('ExecutionService', () => {
waitTill: null,
retrySuccessId: null,
workflowName: expect.any(String),
annotation: {
tags: expect.arrayContaining([]),
vote: null,
},
};
expect(output.count).toBe(2);
@ -462,4 +468,201 @@ describe('ExecutionService', () => {
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 { 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';
describe('softDeleteOnPruningCycle()', () => {
@ -40,7 +44,7 @@ describe('softDeleteOnPruningCycle()', () => {
});
beforeEach(async () => {
await testDb.truncate(['Execution']);
await testDb.truncate(['Execution', 'ExecutionAnnotation']);
});
afterAll(async () => {
@ -138,6 +142,25 @@ describe('softDeleteOnPruningCycle()', () => {
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', () => {
@ -226,5 +249,33 @@ describe('softDeleteOnPruningCycle()', () => {
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 { ExecutionDataRepository } from '@/databases/repositories/execution-data.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(
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() {
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
const repositories = [
'AnnotationTag',
'AuthIdentity',
'AuthProviderSyncHistory',
'Credentials',
'EventDestinations',
'Execution',
'ExecutionAnnotation',
'ExecutionData',
'ExecutionMetadata',
'InstalledNodes',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,32 @@
import type { IRestApiContext, ITag } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
export async function getTags(context: IRestApiContext, withUsageCount = false): Promise<ITag[]> {
return await makeRestApiRequest(context, 'GET', '/tags', { withUsageCount });
type TagsApiEndpoint = '/tags' | '/annotation-tags';
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> {
return await makeRestApiRequest(context, 'POST', '/tags', params);
}
export async function updateTag(
context: IRestApiContext,
id: string,
params: { name: string },
): Promise<ITag> {
return await makeRestApiRequest(context, 'PATCH', `/tags/${id}`, params);
}
export async function deleteTag(context: IRestApiContext, id: string): Promise<boolean> {
return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`);
export function createTagsApi(endpoint: TagsApiEndpoint): ITagsApi {
return {
getTags: async (context: IRestApiContext, withUsageCount = false): Promise<ITag[]> => {
return await makeRestApiRequest(context, 'GET', endpoint, { withUsageCount });
},
createTag: async (context: IRestApiContext, params: { name: string }): Promise<ITag> => {
return await makeRestApiRequest(context, 'POST', endpoint, params);
},
updateTag: async (
context: IRestApiContext,
id: string,
params: { name: string },
): Promise<ITag> => {
return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, params);
},
deleteTag: async (context: IRestApiContext, id: string): Promise<boolean> => {
return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${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 { MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { useToast } from '@/composables/useToast';
import TagsDropdown from '@/components/TagsDropdown.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import Modal from '@/components/Modal.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
@ -16,7 +16,7 @@ import { useRouter } from 'vue-router';
export default defineComponent({
name: 'DuplicateWorkflow',
components: { TagsDropdown, Modal },
components: { WorkflowTagsDropdown, Modal },
props: ['modalName', 'isActive', 'data'],
setup() {
const router = useRouter();
@ -167,7 +167,7 @@ export default defineComponent({
:placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
<TagsDropdown
<WorkflowTagsDropdown
v-if="settingsStore.areTagsEnabled"
ref="dropdown"
v-model="currentTagIds"

View file

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

View file

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

View file

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

View file

@ -1,236 +1,194 @@
<script lang="ts">
import { computed, defineComponent, 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';
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
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>;
type TagRef = InstanceType<typeof N8nOption>;
type CreateRef = InstanceType<typeof N8nOption>;
interface TagsDropdownProps {
placeholder: string;
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 CREATE_KEY = '__create';
export default defineComponent({
name: 'TagsDropdown',
props: {
placeholder: {},
modelValue: {
type: Array as PropType<string[]>,
default: () => [],
},
eventBus: {
type: Object as PropType<EventBus>,
default: null,
},
const selectRef = ref<InstanceType<typeof N8nSelect>>();
const tagRefs = ref<Array<InstanceType<typeof N8nOption>>>();
const createRef = ref<InstanceType<typeof N8nOption>>();
const filter = ref('');
const focused = ref(false);
const preventUpdate = ref(false);
const container = ref<HTMLDivElement>();
const dropdownId = uuid();
const options = computed<ITag[]>(() => {
return props.allTags.filter((tag: ITag) => tag && tag.name.includes(filter.value));
});
const appliedTags = computed<string[]>(() => {
return props.modelValue.filter((id: string) => props.tagsById[id]);
});
watch(
() => props.allTags,
() => {
if (props.modelValue.length !== appliedTags.value.length) {
emit('update:modelValue', appliedTags.value);
}
},
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);
onMounted(() => {
const select = selectRef.value?.innerSelect;
const selectRef = ref<SelectRef | undefined>();
const tagRefs = ref<TagRef[] | undefined>();
const createRef = ref<CreateRef | undefined>();
if (select) {
const input = select.$refs.input as Element | undefined;
const tags = ref([]);
const filter = ref('');
const focused = ref(false);
const preventUpdate = ref(false);
const container = ref<HTMLDivElement | undefined>();
const allTags = computed<ITag[]>(() => {
return tagsStore.allTags;
});
const options = computed<ITag[]>(() => {
return allTags.value.filter((tag: ITag) => tag && tag.name.includes(filter.value));
});
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) {
emit('update:modelValue', appliedTags.value);
}
},
);
onMounted(() => {
const select = selectRef.value?.$refs?.innerSelect as
| { $refs: { input: Element } }
| undefined;
if (select) {
const input = select.$refs.input as Element | undefined;
if (input) {
input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`);
input.addEventListener('keydown', (e: Event) => {
const keyboardEvent = e as KeyboardEvent;
// events don't bubble outside of select, so need to hook onto input
if (keyboardEvent.key === 'Escape') {
emit('esc');
} else if (keyboardEvent.key === 'Enter' && filter.value.length === 0) {
preventUpdate.value = true;
emit('blur');
if (typeof selectRef.value?.blur === 'function') {
selectRef.value.blur();
}
}
});
}
}
props.eventBus?.on('focus', onBusFocus);
void tagsStore.fetchAll();
});
onBeforeUnmount(() => {
props.eventBus?.off('focus', onBusFocus);
});
function onBusFocus() {
focusOnInput();
focusFirstOption();
}
function filterOptions(value = '') {
filter.value = value;
void nextTick(() => focusFirstOption());
}
async function onCreate() {
const name = filter.value;
try {
const newTag = await tagsStore.create(name);
emit('update:modelValue', [...props.modelValue, newTag.id]);
filter.value = '';
} catch (error) {
showError(
error,
i18n.baseText('tagsDropdown.showError.title'),
i18n.baseText('tagsDropdown.showError.message', { interpolate: { name } }),
);
}
}
function onTagsUpdated(selected: string[]) {
const manage = selected.find((value) => value === MANAGE_KEY);
const create = selected.find((value) => value === CREATE_KEY);
if (manage) {
filter.value = '';
uiStore.openModal(TAGS_MANAGER_MODAL_KEY);
emit('blur');
} else if (create) {
void onCreate();
} else {
setTimeout(() => {
if (!preventUpdate.value) {
emit('update:modelValue', selected);
if (input) {
input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`);
input.addEventListener('keydown', (e: Event) => {
const keyboardEvent = e as KeyboardEvent;
if (keyboardEvent.key === 'Escape') {
emit('esc');
} else if (keyboardEvent.key === 'Enter' && filter.value.length === 0) {
preventUpdate.value = true;
emit('blur');
if (typeof selectRef.value?.blur === 'function') {
selectRef.value.blur();
}
preventUpdate.value = false;
}, 0);
}
}
function focusFirstOption() {
// focus on create option
if (createRef.value?.$el) {
createRef.value.$el.dispatchEvent(new Event('mouseenter'));
}
// focus on top option after filter
else if (tagRefs.value?.[0]?.$el) {
tagRefs.value[0].$el.dispatchEvent(new Event('mouseenter'));
}
}
function focusOnInput() {
if (selectRef.value) {
selectRef.value.focusOnInput();
focused.value = true;
}
}
function onVisibleChange(visible: boolean) {
if (!visible) {
filter.value = '';
focused.value = false;
} else {
focused.value = true;
}
}
function onRemoveTag() {
void nextTick(() => {
focusOnInput();
}
});
}
}
onClickOutside(
container,
() => {
emit('blur');
},
{ ignore: ['.tags-dropdown', '#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(),
};
},
props.eventBus?.on('focus', onBusFocus);
});
onBeforeUnmount(() => {
props.eventBus?.off('focus', onBusFocus);
});
function onBusFocus() {
focusOnInput();
focusFirstOption();
}
function filterOptions(value = '') {
filter.value = value;
void nextTick(() => focusFirstOption());
}
async function onCreate() {
const name = filter.value;
try {
const newTag = await props.createTag(name);
emit('update:modelValue', [...props.modelValue, newTag.id]);
filter.value = '';
} catch (error) {
showError(
error,
i18n.baseText('tagsDropdown.showError.title'),
i18n.baseText('tagsDropdown.showError.message', { interpolate: { name } }),
);
}
}
function onTagsUpdated(selected: string[]) {
const manage = selected.find((value) => value === MANAGE_KEY);
const create = selected.find((value) => value === CREATE_KEY);
if (manage) {
filter.value = '';
emit('manage-tags');
emit('blur');
} else if (create) {
void onCreate();
} else {
setTimeout(() => {
if (!preventUpdate.value) {
emit('update:modelValue', selected);
}
preventUpdate.value = false;
}, 0);
}
}
function focusFirstOption() {
// focus on create option
if (createRef.value?.$el) {
createRef.value.$el.dispatchEvent(new Event('mouseenter'));
}
// focus on top option after filter
else if (tagRefs.value?.[0]?.$el) {
tagRefs.value[0].$el.dispatchEvent(new Event('mouseenter'));
}
}
function focusOnInput() {
if (selectRef.value) {
selectRef.value.focusOnInput();
focused.value = true;
}
}
function onVisibleChange(visible: boolean) {
if (!visible) {
filter.value = '';
focused.value = false;
} else {
focused.value = true;
}
}
function onRemoveTag() {
void nextTick(() => {
focusOnInput();
});
}
onClickOutside(
container,
() => {
emit('blur');
},
{ ignore: [`.tags-dropdown-${dropdownId}`, '#tags-manager-modal'] },
);
</script>
<template>
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
<n8n-select
<N8nSelect
ref="selectRef"
:teleported="true"
:model-value="appliedTags"
@ -241,13 +199,13 @@ export default defineComponent({
multiple
:reserve-keyword="false"
loading-text="..."
popper-class="tags-dropdown"
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId]"
data-test-id="tags-dropdown"
@update:model-value="onTagsUpdated"
@visible-change="onVisibleChange"
@remove-tag="onRemoveTag"
>
<n8n-option
<N8nOption
v-if="options.length === 0 && filter"
:key="CREATE_KEY"
ref="createRef"
@ -258,17 +216,16 @@ export default defineComponent({
<span>
{{ i18n.baseText('tagsDropdown.createTag', { interpolate: { filter } }) }}
</span>
</n8n-option>
<n8n-option v-else-if="options.length === 0" value="message" disabled>
</N8nOption>
<N8nOption v-else-if="options.length === 0" value="message" disabled>
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="allTags.length > 0">{{
i18n.baseText('tagsDropdown.noMatchingTagsExist')
}}</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 -->
<n8n-option
<N8nOption
v-for="(tag, i) in options"
:key="tag.id + '_' + i"
ref="tagRefs"
@ -278,11 +235,11 @@ export default defineComponent({
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" />
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
</n8n-option>
</n8n-select>
</N8nOption>
</N8nSelect>
</div>
</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>
<div :class="$style.container">
<el-col class="notags" :span="16">
@ -5,11 +19,11 @@
<div>
<div class="mb-s">
<n8n-heading size="large">
{{ $locale.baseText('noTagsView.readyToOrganizeYourWorkflows') }}
{{ $locale.baseText(titleLocaleKey) }}
</n8n-heading>
</div>
<div class="description">
{{ $locale.baseText('noTagsView.withWorkflowTagsYouReFree') }}
{{ $locale.baseText(descriptionLocaleKey) }}
</div>
</div>
<n8n-button label="Create a tag" size="large" @click="$emit('enableCreate')" />

View file

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

View file

@ -2,8 +2,10 @@
import type { ElTable } from 'element-plus';
import { MAX_TAG_NAME_LENGTH } from '@/constants';
import type { ITagRow } from '@/Interface';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import type { N8nInput } from 'n8n-design-system';
import type { BaseTextKey } from '@/plugins/i18n';
type TableRef = InstanceType<typeof ElTable>;
type N8nInputRef = InstanceType<typeof N8nInput>;
@ -13,7 +15,28 @@ const DELETE_TRANSITION_TIMEOUT = 100;
export default defineComponent({
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() {
return {
maxLength: MAX_TAG_NAME_LENGTH,
@ -139,7 +162,7 @@ export default defineComponent({
</div>
</template>
</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">
<transition name="fade" mode="out-in">
<div

View file

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

View file

@ -7,14 +7,15 @@ import type {
IWorkflowDb,
} from '@/Interface';
import { i18n as locale } from '@/plugins/i18n';
import TagsDropdown from '@/components/TagsDropdown.vue';
import { getObjectKeys, isEmpty } from '@/utils/typesUtils';
import { EnterpriseEditionFeature } from '@/constants';
import { EnterpriseEditionFeature, EXECUTION_ANNOTATION_EXPERIMENT } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { usePostHog } from '@/stores/posthog.store';
import { useTelemetry } from '@/composables/useTelemetry';
import type { Placement } from '@floating-ui/core';
import { useDebounce } from '@/composables/useDebounce';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
export type ExecutionFilterProps = {
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;
@ -26,6 +27,7 @@ const DATE_TIME_MASK = 'YYYY-MM-DD HH:mm';
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const posthogStore = usePostHog();
const { debounce } = useDebounce();
const telemetry = useTelemetry();
@ -46,15 +48,22 @@ const isCustomDataFilterTracked = ref(false);
const isAdvancedExecutionFilterEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
);
const isAnnotationFiltersEnabled = computed(
() =>
isAdvancedExecutionFilterEnabled.value &&
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
);
const showTags = computed(() => false);
const getDefaultFilter = (): ExecutionFilterType => ({
status: 'all',
workflowId: 'all',
tags: [],
annotationTags: [],
startDate: '',
endDate: '',
metadata: [{ key: '', value: '' }],
vote: 'all',
});
const filter = reactive(getDefaultFilter());
@ -90,27 +99,25 @@ const statuses = computed(() => [
{ 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(() => {
let count = 0;
if (filter.status !== 'all') {
count++;
}
if (filter.workflowId !== 'all' && props.workflows.length) {
count++;
}
if (!isEmpty(filter.tags)) {
count++;
}
if (!isEmpty(filter.metadata)) {
count++;
}
if (!!filter.startDate) {
count++;
}
if (!!filter.endDate) {
count++;
}
return count;
const nonDefaultFilters = [
filter.status !== 'all',
filter.workflowId !== 'all' && props.workflows.length,
!isEmpty(filter.tags),
!isEmpty(filter.annotationTags),
filter.vote !== 'all',
!isEmpty(filter.metadata),
!!filter.startDate,
!!filter.endDate,
].filter(Boolean);
return nonDefaultFilters.length;
});
// 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
// We just emit the updated filter
const onTagsChange = (tags: string[]) => {
filter.tags = tags;
const onTagsChange = () => {
emit('filterChanged', filter);
};
const onAnnotationTagsChange = () => {
emit('filterChanged', filter);
};
@ -194,10 +204,10 @@ onBeforeMount(() => {
</div>
<div v-if="showTags" :class="$style.group">
<label for="execution-filter-tags">{{ locale.baseText('workflows.filters.tags') }}</label>
<TagsDropdown
<WorkflowTagsDropdown
id="execution-filter-tags"
v-model="filter.tags"
:placeholder="locale.baseText('workflowOpen.filterWorkflows')"
:model-value="filter.tags"
:create-enabled="false"
data-test-id="executions-filter-tags-select"
@update:model-value="onTagsChange"
@ -247,19 +257,43 @@ onBeforeMount(() => {
/>
</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">
<n8n-tooltip placement="right">
<template #content>
<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>
<i18n-t tag="span" keypath="executionsFilter.customData.docsTooltip" />
</template>
<span :class="$style.label">
{{ 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 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', () => ({
useRoute: () => ({
@ -9,6 +11,24 @@ vi.mock('vue-router', () => ({
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, {
global: {
stubs: {
@ -26,7 +46,7 @@ const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
describe('WorkflowExecutionsCard', () => {
beforeEach(() => {
setActivePinia(createPinia());
setActivePinia(createTestingPinia({ initialState }));
});
test.each([

View file

@ -2,13 +2,15 @@
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
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 { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { ExecutionSummary } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@/composables/useI18n';
import type { PermissionsRecord } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
const props = defineProps<{
execution: ExecutionSummary;
@ -27,6 +29,17 @@ const locale = useI18n();
const executionHelpers = useExecutionHelpers();
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 retryExecutionActions = computed(() => [
@ -110,6 +123,21 @@ function onRetryMenuItemSelect(action: string): void {
{{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
</N8nText>
</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 :class="$style.icons">
<N8nActionDropdown
@ -221,6 +249,23 @@ function onRetryMenuItemSelect(action: string): void {
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 {
@ -269,6 +314,7 @@ function onRetryMenuItemSelect(action: string): void {
margin-left: var(--spacing-2xs);
}
}
.showGap {
margin-bottom: var(--spacing-2xs);
.executionLink {

View file

@ -2,11 +2,18 @@
import { computed, watch } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';
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 { ExecutionSummary } from 'n8n-workflow';
import { getNodeViewTab } from '@/utils/canvasUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
const props = withDefaults(
defineProps<{
@ -36,6 +43,18 @@ const emit = defineEmits<{
const workflowHelpers = useWorkflowHelpers({ 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>(() =>
props.executions.find((execution) => execution.id === props.execution?.id)
? undefined
@ -116,6 +135,10 @@ onBeforeRouteLeave(async (to, _, next) => {
@stop-execution="onStopExecution"
/>
</div>
<WorkflowExecutionAnnotationSidebar
v-if="isAnnotationEnabled && execution"
:execution="execution"
/>
</div>
</template>

View file

@ -8,6 +8,7 @@ export interface IExecutionUIData {
startTime: string;
runningTime: string;
showTimestamp: boolean;
tags: Array<{ id: string; name: string }>;
}
export function useExecutionHelpers() {
@ -20,6 +21,7 @@ export function useExecutionHelpers() {
label: 'Status unknown',
runningTime: '',
showTimestamp: true,
tags: execution.annotation?.tags ?? [],
};
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 DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
export const VERSIONS_MODAL_KEY = 'versions';
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
@ -630,6 +631,7 @@ export const enum STORES {
NODE_TYPES = 'nodeTypes',
CREDENTIALS = 'credentials',
TAGS = 'tags',
ANNOTATION_TAGS = 'annotationTags',
VERSIONS = 'versions',
NODE_CREATOR = 'nodeCreator',
WEBHOOKS = 'webhooks',
@ -691,6 +693,8 @@ export const MORE_ONBOARDING_OPTIONS_EXPERIMENT = {
variant: 'variant',
};
export const EXECUTION_ANNOTATION_EXPERIMENT = '023_execution_annotation';
export const EXPERIMENTS_TO_TRACK = [
ASK_AI_EXPERIMENT.name,
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,

View file

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

View file

@ -28,6 +28,8 @@
"clientSecret": "Client Secret"
}
},
"generic.annotations": "Annotations",
"generic.annotationData": "Highlighted data",
"generic.any": "Any",
"generic.cancel": "Cancel",
"generic.close": "Close",
@ -51,6 +53,7 @@
"generic.beta": "beta",
"generic.yes": "Yes",
"generic.no": "No",
"generic.rating": "Rating",
"generic.retry": "Retry",
"generic.error": "Something went wrong",
"generic.settings": "Settings",
@ -96,6 +99,9 @@
"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.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.currentPassword": "Current password",
"auth.changePassword.mfaCode": "Two-factor code",
@ -759,12 +765,23 @@
"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.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.selectWorkflow": "Select Workflow",
"executionsFilter.start": "Execution start",
"executionsFilter.startDate": "Earliest",
"executionsFilter.endDate": "Latest",
"executionsFilter.savedData": "Custom data (saved in execution)",
"executionsFilter.savedData": "Highlighted data",
"executionsFilter.savedDataKey": "Key",
"executionsFilter.savedDataKeyPlaceholder": "ID",
"executionsFilter.savedDataValue": "Value (exact match)",
@ -772,7 +789,7 @@
"executionsFilter.reset": "Reset all",
"executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}",
"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",
"expressionEdit.anythingInside": "Anything inside ",
"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.",
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
"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.activateDeactivateNode": "Activate/Deactivate Node",
"node.changeColor": "Change color",
@ -1914,6 +1933,8 @@
"tagsManager.couldNotDeleteTag": "Could not delete tag",
"tagsManager.done": "Done",
"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.title": "Could not create tag",
"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 { computed, ref } from 'vue';
import type { IDataObject, ExecutionSummary } from 'n8n-workflow';
import type { IDataObject, ExecutionSummary, AnnotationVote } from 'n8n-workflow';
import type {
ExecutionFilterType,
ExecutionsQueryFilter,
@ -82,9 +82,12 @@ export const useExecutionsStore = defineStore('executions', () => {
const allExecutions = computed(() => [...currentExecutions.value, ...executions.value]);
function addExecution(execution: ExecutionSummaryWithScopes) {
executionsById.value[execution.id] = {
...execution,
mode: execution.mode,
executionsById.value = {
...executionsById.value,
[execution.id]: {
...execution,
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> {
return await makeRestApiRequest(
rootStore.restApiContext,
@ -245,6 +266,7 @@ export const useExecutionsStore = defineStore('executions', () => {
return {
loading,
annotateExecution,
executionsById,
executions,
executionsCount,

View file

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

View file

@ -1,4 +1,4 @@
import * as tagsApi from '@/api/tags';
import { createTagsApi } from '@/api/tags';
import { STORES } from '@/constants';
import type { ITag } from '@/Interface';
import { defineStore } from 'pinia';
@ -6,109 +6,129 @@ import { useRootStore } from './root.store';
import { computed, ref } from 'vue';
import { useWorkflowsStore } from './workflows.store';
export const useTagsStore = defineStore(STORES.TAGS, () => {
const tagsById = ref<Record<string, ITag>>({});
const loading = ref(false);
const fetchedAll = ref(false);
const fetchedUsageCount = ref(false);
const apiMapping = {
[STORES.TAGS]: createTagsApi('/tags'),
[STORES.ANNOTATION_TAGS]: createTagsApi('/annotation-tags'),
};
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => {
const tagsApi = apiMapping[id];
// Computed
return defineStore(
id,
() => {
const tagsById = ref<Record<string, ITag>>({});
const loading = ref(false);
const fetchedAll = ref(false);
const fetchedUsageCount = ref(false);
const allTags = computed(() => {
return Object.values(tagsById.value).sort((a, b) => a.name.localeCompare(b.name));
});
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const isLoading = computed(() => loading.value);
// Computed
const hasTags = computed(() => Object.keys(tagsById.value).length > 0);
const allTags = computed(() => {
return Object.values(tagsById.value).sort((a, b) => a.name.localeCompare(b.name));
});
// Methods
const isLoading = computed(() => loading.value);
const setAllTags = (loadedTags: ITag[]) => {
tagsById.value = loadedTags.reduce((accu: { [id: string]: ITag }, tag: ITag) => {
accu[tag.id] = tag;
const hasTags = computed(() => Object.keys(tagsById.value).length > 0);
return accu;
}, {});
fetchedAll.value = true;
};
// Methods
const upsertTags = (toUpsertTags: ITag[]) => {
toUpsertTags.forEach((toUpsertTag) => {
const tagId = toUpsertTag.id;
const currentTag = tagsById.value[tagId];
if (currentTag) {
const newTag = {
...currentTag,
...toUpsertTag,
};
tagsById.value = {
...tagsById.value,
[tagId]: newTag,
};
} else {
tagsById.value = {
...tagsById.value,
[tagId]: toUpsertTag,
};
}
});
};
const setAllTags = (loadedTags: ITag[]) => {
tagsById.value = loadedTags.reduce((accu: { [id: string]: ITag }, tag: ITag) => {
accu[tag.id] = tag;
const deleteTag = (id: string) => {
const { [id]: deleted, ...rest } = tagsById.value;
tagsById.value = rest;
};
return accu;
}, {});
fetchedAll.value = true;
};
const fetchAll = async (params?: { force?: boolean; withUsageCount?: boolean }) => {
const { force = false, withUsageCount = false } = params || {};
if (!force && fetchedAll.value && fetchedUsageCount.value === withUsageCount) {
return Object.values(tagsById.value);
}
const upsertTags = (toUpsertTags: ITag[]) => {
toUpsertTags.forEach((toUpsertTag) => {
const tagId = toUpsertTag.id;
const currentTag = tagsById.value[tagId];
if (currentTag) {
const newTag = {
...currentTag,
...toUpsertTag,
};
tagsById.value = {
...tagsById.value,
[tagId]: newTag,
};
} else {
tagsById.value = {
...tagsById.value,
[tagId]: toUpsertTag,
};
}
});
};
loading.value = true;
const retrievedTags = await tagsApi.getTags(rootStore.restApiContext, Boolean(withUsageCount));
setAllTags(retrievedTags);
loading.value = false;
return retrievedTags;
};
const deleteTag = (id: string) => {
const { [id]: deleted, ...rest } = tagsById.value;
tagsById.value = rest;
};
const create = async (name: string) => {
const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name });
upsertTags([createdTag]);
return createdTag;
};
const fetchAll = async (params?: { force?: boolean; withUsageCount?: boolean }) => {
const { force = false, withUsageCount = false } = params || {};
if (!force && fetchedAll.value && fetchedUsageCount.value === withUsageCount) {
return Object.values(tagsById.value);
}
const rename = async ({ id, name }: { id: string; name: string }) => {
const updatedTag = await tagsApi.updateTag(rootStore.restApiContext, id, { name });
upsertTags([updatedTag]);
return updatedTag;
};
loading.value = true;
const retrievedTags = await tagsApi.getTags(
rootStore.restApiContext,
Boolean(withUsageCount),
);
setAllTags(retrievedTags);
loading.value = false;
return retrievedTags;
};
const deleteTagById = async (id: string) => {
const deleted = await tagsApi.deleteTag(rootStore.restApiContext, id);
const create = async (name: string) => {
const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name });
upsertTags([createdTag]);
return createdTag;
};
if (deleted) {
deleteTag(id);
workflowsStore.removeWorkflowTagId(id);
}
const rename = async ({ id, name }: { id: string; name: string }) => {
const updatedTag = await tagsApi.updateTag(rootStore.restApiContext, id, { name });
upsertTags([updatedTag]);
return updatedTag;
};
return deleted;
};
const deleteTagById = async (id: string) => {
const deleted = await tagsApi.deleteTag(rootStore.restApiContext, id);
return {
allTags,
isLoading,
hasTags,
tagsById,
fetchAll,
create,
rename,
deleteTagById,
upsertTags,
deleteTag,
};
});
if (deleted) {
deleteTag(id);
workflowsStore.removeWorkflowTagId(id);
}
return deleted;
};
return {
allTags,
isLoading,
hasTags,
tagsById,
fetchAll,
create,
rename,
deleteTagById,
upsertTags,
deleteTag,
};
},
{},
);
};
export const useTagsStore = createTagsStore(STORES.TAGS);
export const useAnnotationTagsStore = createTagsStore(STORES.ANNOTATION_TAGS);

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ export class ExecutionData implements INodeType {
properties: [
{
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',
type: 'notice',
default: '',
@ -38,9 +38,9 @@ export class ExecutionData implements INodeType {
noDataExpression: true,
options: [
{
name: 'Save Execution Data for Search',
name: 'Save Highlight Data (for Search/review)',
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[];
}
export type AnnotationVote = 'up' | 'down';
export interface ExecutionSummary {
id: string;
finished?: boolean;
@ -2452,6 +2454,13 @@ export interface ExecutionSummary {
nodeExecutionStatus?: {
[key: string]: IExecutionSummaryNodeExecutionResult;
};
annotation?: {
vote: AnnotationVote;
tags: Array<{
id: string;
name: string;
}>;
};
}
export interface IExecutionSummaryNodeExecutionResult {