mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
feat(core): Execution curation (#10342)
Co-authored-by: oleg <me@olegivaniv.com>
This commit is contained in:
parent
8603946e23
commit
022ddcbef9
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
45
packages/cli/src/controllers/annotation-tags.controller.ts
Normal file
45
packages/cli/src/controllers/annotation-tags.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
20
packages/cli/src/databases/entities/annotation-tag-entity.ts
Normal file
20
packages/cli/src/databases/entities/annotation-tag-entity.ts
Normal 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[];
|
||||
}
|
|
@ -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[];
|
||||
}
|
61
packages/cli/src/databases/entities/execution-annotation.ts
Normal file
61
packages/cli/src/databases/entities/execution-annotation.ts
Normal 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[];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
...(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' } });
|
||||
|
||||
|
|
|
@ -25,6 +25,8 @@ describe('ExecutionService', () => {
|
|||
mock(),
|
||||
mock(),
|
||||
activeExecutions,
|
||||
mock(),
|
||||
mock(),
|
||||
executionRepository,
|
||||
mock(),
|
||||
mock(),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
30
packages/cli/src/executions/validation.ts
Normal file
30
packages/cli/src/executions/validation.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
// ----------------------------------
|
||||
|
|
|
@ -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';
|
||||
|
|
52
packages/cli/src/services/annotation-tag.service.ts
Normal file
52
packages/cli/src/services/annotation-tag.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 })));
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -26,6 +26,7 @@ type EndpointGroup =
|
|||
| 'eventBus'
|
||||
| 'license'
|
||||
| 'variables'
|
||||
| 'annotationTags'
|
||||
| 'tags'
|
||||
| 'externalSecrets'
|
||||
| 'mfa'
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -100,6 +100,7 @@ defineExpose({
|
|||
focus,
|
||||
blur,
|
||||
focusOnInput,
|
||||
innerSelect,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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(
|
||||
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', `/tags/${id}`, params);
|
||||
}
|
||||
|
||||
export async function deleteTag(context: IRestApiContext, id: string): Promise<boolean> {
|
||||
return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`);
|
||||
): 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}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>
|
74
packages/editor-ui/src/components/AnnotationTagsDropdown.vue
Normal file
74
packages/editor-ui/src/components/AnnotationTagsDropdown.vue
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -1,83 +1,71 @@
|
|||
<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: () => {},
|
||||
};
|
||||
},
|
||||
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
|
||||
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;
|
||||
|
||||
let toDisplay: TagEl[] = this.limit ? tags.slice(0, this.limit) : tags;
|
||||
toDisplay = toDisplay.map((tag: ITag) => ({
|
||||
...tag,
|
||||
hidden: this.responsive && !this.visibility[tag.id],
|
||||
hidden: props.responsive && !visibility.value[tag.id],
|
||||
}));
|
||||
|
||||
let visibleCount = toDisplay.length;
|
||||
if (this.responsive) {
|
||||
visibleCount = Object.values(this.visibility).reduce(
|
||||
if (props.responsive) {
|
||||
visibleCount = Object.values(visibility.value).reduce(
|
||||
(accu, val) => (val ? accu + 1 : accu),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
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 (visibleCount < allTags.length) {
|
||||
const hidden = allTags.slice(visibleCount);
|
||||
const hiddenTitle = hidden.reduce(
|
||||
(accu: string, tag: ITag) => (accu ? `${accu}, ${tag.name}` : tag.name),
|
||||
'',
|
||||
);
|
||||
|
||||
const countTag: TagEl = {
|
||||
const countTag = {
|
||||
id: 'count',
|
||||
name: `+${hidden.length}`,
|
||||
title: hiddenTitle,
|
||||
|
@ -87,47 +75,47 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const setMaxWidth = () => {
|
||||
const container = tagsContainer.value?.$el as HTMLElement;
|
||||
const parent = container?.parentNode as HTMLElement;
|
||||
|
||||
if (parent) {
|
||||
this.maxWidth = 0;
|
||||
void this.$nextTick(() => {
|
||||
this.maxWidth = parent.clientWidth;
|
||||
maxWidth.value = 0;
|
||||
void nextTick(() => {
|
||||
maxWidth.value = parent.clientWidth;
|
||||
});
|
||||
}
|
||||
},
|
||||
onObserved({ el, isIntersecting }: { el: HTMLElement; isIntersecting: boolean }) {
|
||||
};
|
||||
|
||||
const debouncedSetMaxWidth = debounce(setMaxWidth, 100);
|
||||
|
||||
const onObserved = ({ el, isIntersecting }: { el: HTMLElement; isIntersecting: boolean }) => {
|
||||
if (el.dataset.id) {
|
||||
this.visibility = { ...this.visibility, [el.dataset.id]: isIntersecting };
|
||||
visibility.value = { ...visibility.value, [el.dataset.id]: isIntersecting };
|
||||
}
|
||||
},
|
||||
onClick(e: MouseEvent, tag: TagEl) {
|
||||
if (this.clickable) {
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent, tag: ITag & { hidden?: boolean }) => {
|
||||
if (props.clickable) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// if tag is hidden or not displayed
|
||||
if (!tag.hidden) {
|
||||
this.$emit('click', tag.id);
|
||||
emit('click', tag.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
setMaxWidth();
|
||||
window.addEventListener('resize', debouncedSetMaxWidth);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', debouncedSetMaxWidth);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,102 +1,88 @@
|
|||
<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,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
'update:modelValue': null,
|
||||
esc: null,
|
||||
blur: null,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const i18n = useI18n();
|
||||
const { showError } = useToast();
|
||||
const tagsStore = useTagsStore();
|
||||
const uiStore = useUIStore();
|
||||
const selectRef = ref<InstanceType<typeof N8nSelect>>();
|
||||
const tagRefs = ref<Array<InstanceType<typeof N8nOption>>>();
|
||||
const createRef = ref<InstanceType<typeof N8nOption>>();
|
||||
|
||||
const { isLoading } = storeToRefs(tagsStore);
|
||||
const filter = ref('');
|
||||
const focused = ref(false);
|
||||
const preventUpdate = ref(false);
|
||||
|
||||
const selectRef = ref<SelectRef | undefined>();
|
||||
const tagRefs = ref<TagRef[] | undefined>();
|
||||
const createRef = ref<CreateRef | undefined>();
|
||||
const container = ref<HTMLDivElement>();
|
||||
|
||||
const tags = ref([]);
|
||||
const filter = ref('');
|
||||
const focused = ref(false);
|
||||
const preventUpdate = ref(false);
|
||||
const dropdownId = uuid();
|
||||
|
||||
const container = ref<HTMLDivElement | undefined>();
|
||||
const options = computed<ITag[]>(() => {
|
||||
return props.allTags.filter((tag: ITag) => tag && tag.name.includes(filter.value));
|
||||
});
|
||||
|
||||
const allTags = computed<ITag[]>(() => {
|
||||
return tagsStore.allTags;
|
||||
});
|
||||
const appliedTags = computed<string[]>(() => {
|
||||
return props.modelValue.filter((id: string) => props.tagsById[id]);
|
||||
});
|
||||
|
||||
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,
|
||||
watch(
|
||||
() => props.allTags,
|
||||
() => {
|
||||
// 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?.innerSelect;
|
||||
|
||||
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();
|
||||
}
|
||||
|
@ -106,28 +92,26 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
props.eventBus?.on('focus', onBusFocus);
|
||||
});
|
||||
|
||||
void tagsStore.fetchAll();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onBeforeUnmount(() => {
|
||||
props.eventBus?.off('focus', onBusFocus);
|
||||
});
|
||||
});
|
||||
|
||||
function onBusFocus() {
|
||||
function onBusFocus() {
|
||||
focusOnInput();
|
||||
focusFirstOption();
|
||||
}
|
||||
}
|
||||
|
||||
function filterOptions(value = '') {
|
||||
function filterOptions(value = '') {
|
||||
filter.value = value;
|
||||
void nextTick(() => focusFirstOption());
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
async function onCreate() {
|
||||
const name = filter.value;
|
||||
try {
|
||||
const newTag = await tagsStore.create(name);
|
||||
const newTag = await props.createTag(name);
|
||||
emit('update:modelValue', [...props.modelValue, newTag.id]);
|
||||
|
||||
filter.value = '';
|
||||
|
@ -138,15 +122,15 @@ export default defineComponent({
|
|||
i18n.baseText('tagsDropdown.showError.message', { interpolate: { name } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onTagsUpdated(selected: string[]) {
|
||||
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('manage-tags');
|
||||
emit('blur');
|
||||
} else if (create) {
|
||||
void onCreate();
|
||||
|
@ -158,9 +142,9 @@ export default defineComponent({
|
|||
preventUpdate.value = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusFirstOption() {
|
||||
function focusFirstOption() {
|
||||
// focus on create option
|
||||
if (createRef.value?.$el) {
|
||||
createRef.value.$el.dispatchEvent(new Event('mouseenter'));
|
||||
|
@ -169,68 +153,42 @@ export default defineComponent({
|
|||
else if (tagRefs.value?.[0]?.$el) {
|
||||
tagRefs.value[0].$el.dispatchEvent(new Event('mouseenter'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnInput() {
|
||||
function focusOnInput() {
|
||||
if (selectRef.value) {
|
||||
selectRef.value.focusOnInput();
|
||||
focused.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibleChange(visible: boolean) {
|
||||
function onVisibleChange(visible: boolean) {
|
||||
if (!visible) {
|
||||
filter.value = '';
|
||||
focused.value = false;
|
||||
} else {
|
||||
focused.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onRemoveTag() {
|
||||
function onRemoveTag() {
|
||||
void nextTick(() => {
|
||||
focusOnInput();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(
|
||||
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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
{ 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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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')" />
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
const props = withDefaults(defineProps<TagsManagerProps>(), {
|
||||
titleLocaleKey: 'tagsManager.manageTags',
|
||||
usageLocaleKey: 'tagsView.inUse',
|
||||
usageColumnTitleLocaleKey: 'tagsTable.usage',
|
||||
noTagsTitleLocaleKey: 'noTagsView.readyToOrganizeYourWorkflows',
|
||||
noTagsDescriptionLocaleKey: 'noTagsView.withWorkflowTagsYouReFree',
|
||||
});
|
||||
|
||||
async onCreate(name: string, cb: (tag: ITag | null, error?: Error) => void) {
|
||||
const emit = defineEmits<{
|
||||
'update:tags': [tags: ITag[]];
|
||||
}>();
|
||||
|
||||
const tagIds = ref(props.tags.map((tag) => tag.id));
|
||||
const isCreating = ref(false);
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const tags = computed(() =>
|
||||
tagIds.value
|
||||
.map((tagId) => props.tags.find((tag) => tag.id === tagId))
|
||||
.filter((tag): tag is ITag => Boolean(tag)),
|
||||
);
|
||||
const hasTags = computed(() => tags.value.length > 0);
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
onMounted(() => {
|
||||
void props.onFetchTags();
|
||||
});
|
||||
|
||||
function onEnableCreate() {
|
||||
isCreating.value = true;
|
||||
}
|
||||
|
||||
function onDisableCreate() {
|
||||
isCreating.value = false;
|
||||
}
|
||||
|
||||
async function onCreate(name: string, createCallback: (tag: ITag | null, error?: Error) => void) {
|
||||
try {
|
||||
if (!name) {
|
||||
throw new Error(this.$locale.baseText('tagsManager.tagNameCannotBeEmpty'));
|
||||
throw new Error(i18n.baseText('tagsManager.tagNameCannotBeEmpty'));
|
||||
}
|
||||
|
||||
const newTag = await this.tagsStore.create(name);
|
||||
this.tagIds = [newTag.id].concat(this.tagIds);
|
||||
cb(newTag);
|
||||
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);
|
||||
this.showError(
|
||||
error,
|
||||
this.$locale.baseText('tagsManager.showError.onCreate.title'),
|
||||
this.$locale.baseText('tagsManager.showError.onCreate.message', {
|
||||
interpolate: { escapedName },
|
||||
}) + ':',
|
||||
);
|
||||
cb(null, error);
|
||||
// const escapedName = escape(name);
|
||||
// Implement showError function or emit an event for error handling
|
||||
createCallback(null, error as Error);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async onUpdate(id: string, name: string, cb: (tag: boolean, error?: Error) => void) {
|
||||
const tag = this.tagsStore.tagsById[id];
|
||||
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(this.$locale.baseText('tagsManager.tagNameCannotBeEmpty'));
|
||||
throw new Error(i18n.baseText('tagsManager.tagNameCannotBeEmpty'));
|
||||
}
|
||||
|
||||
if (name === oldName) {
|
||||
cb(true);
|
||||
updateCallback(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 },
|
||||
}) + ':',
|
||||
const updatedTag = await props.onUpdateTag(id, name);
|
||||
emit(
|
||||
'update:tags',
|
||||
props.tags.map((t) => (t.id === id ? updatedTag : t)),
|
||||
);
|
||||
cb(false, error);
|
||||
updateCallback(true);
|
||||
} catch (error) {
|
||||
updateCallback(false, error as Error);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async onDelete(id: string, cb: (deleted: boolean, error?: Error) => void) {
|
||||
const tag = this.tagsStore.tagsById[id];
|
||||
const name = tag.name;
|
||||
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 this.tagsStore.deleteTagById(id);
|
||||
const deleted = await props.onDeleteTag(id);
|
||||
if (!deleted) {
|
||||
throw new Error(this.$locale.baseText('tagsManager.couldNotDeleteTag'));
|
||||
throw new Error(i18n.baseText('tagsManager.couldNotDeleteTag'));
|
||||
}
|
||||
|
||||
this.tagIds = this.tagIds.filter((tagId: string) => tagId !== id);
|
||||
|
||||
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 },
|
||||
}) + ':',
|
||||
tagIds.value = tagIds.value.filter((tagId) => tagId !== id);
|
||||
emit(
|
||||
'update:tags',
|
||||
props.tags.filter((t) => t.id !== id),
|
||||
);
|
||||
cb(false, error);
|
||||
deleteCallback(deleted);
|
||||
} catch (error) {
|
||||
deleteCallback(false, error as Error);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onEnter() {
|
||||
if (this.isLoading) {
|
||||
function onEnter() {
|
||||
if (props.isLoading) {
|
||||
return;
|
||||
} else if (!this.hasTags) {
|
||||
this.onEnableCreate();
|
||||
} else if (!hasTags.value) {
|
||||
onEnableCreate();
|
||||
} else {
|
||||
this.modalBus.emit('close');
|
||||
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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
40
packages/editor-ui/src/components/WorkflowTagsContainer.vue
Normal file
40
packages/editor-ui/src/components/WorkflowTagsContainer.vue
Normal 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>
|
74
packages/editor-ui/src/components/WorkflowTagsDropdown.vue
Normal file
74
packages/editor-ui/src/components/WorkflowTagsDropdown.vue
Normal 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>
|
|
@ -14,9 +14,11 @@ const defaultFilterState: ExecutionFilterType = {
|
|||
status: 'all',
|
||||
workflowId: 'all',
|
||||
tags: [],
|
||||
annotationTags: [],
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
metadata: [{ key: '', value: '' }],
|
||||
vote: 'all',
|
||||
};
|
||||
|
||||
const workflowDataFactory = (): IWorkflowShortResponse => ({
|
||||
|
|
|
@ -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') }}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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([
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {},
|
||||
|
|
|
@ -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, it’s 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}'",
|
||||
|
|
|
@ -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] = {
|
||||
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,
|
||||
|
|
|
@ -14,6 +14,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
|||
const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({
|
||||
workflow: {},
|
||||
tag: {},
|
||||
annotationTag: {},
|
||||
user: {},
|
||||
credential: {},
|
||||
variable: {},
|
||||
|
|
|
@ -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,7 +6,17 @@ import { useRootStore } from './root.store';
|
|||
import { computed, ref } from 'vue';
|
||||
import { useWorkflowsStore } from './workflows.store';
|
||||
|
||||
export const useTagsStore = defineStore(STORES.TAGS, () => {
|
||||
const apiMapping = {
|
||||
[STORES.TAGS]: createTagsApi('/tags'),
|
||||
[STORES.ANNOTATION_TAGS]: createTagsApi('/annotation-tags'),
|
||||
};
|
||||
|
||||
const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => {
|
||||
const tagsApi = apiMapping[id];
|
||||
|
||||
return defineStore(
|
||||
id,
|
||||
() => {
|
||||
const tagsById = ref<Record<string, ITag>>({});
|
||||
const loading = ref(false);
|
||||
const fetchedAll = ref(false);
|
||||
|
@ -70,7 +80,10 @@ export const useTagsStore = defineStore(STORES.TAGS, () => {
|
|||
}
|
||||
|
||||
loading.value = true;
|
||||
const retrievedTags = await tagsApi.getTags(rootStore.restApiContext, Boolean(withUsageCount));
|
||||
const retrievedTags = await tagsApi.getTags(
|
||||
rootStore.restApiContext,
|
||||
Boolean(withUsageCount),
|
||||
);
|
||||
setAllTags(retrievedTags);
|
||||
loading.value = false;
|
||||
return retrievedTags;
|
||||
|
@ -111,4 +124,11 @@ export const useTagsStore = defineStore(STORES.TAGS, () => {
|
|||
upsertTags,
|
||||
deleteTag,
|
||||
};
|
||||
});
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
export const useTagsStore = createTagsStore(STORES.TAGS);
|
||||
|
||||
export const useAnnotationTagsStore = createTagsStore(STORES.ANNOTATION_TAGS);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue