mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-13 16:14:07 -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);
|
executionsTab.actions.createManualExecutions(5);
|
||||||
// Make some failed executions by enabling Code node with syntax error
|
// Make some failed executions by enabling Code node with syntax error
|
||||||
executionsTab.actions.toggleNodeEnabled('Error');
|
executionsTab.actions.toggleNodeEnabled('Error');
|
||||||
|
workflowPage.getters.disabledNodes().should('have.length', 0);
|
||||||
executionsTab.actions.createManualExecutions(2);
|
executionsTab.actions.createManualExecutions(2);
|
||||||
// Then add some more successful ones
|
// Then add some more successful ones
|
||||||
executionsTab.actions.toggleNodeEnabled('Error');
|
executionsTab.actions.toggleNodeEnabled('Error');
|
||||||
|
workflowPage.getters.disabledNodes().should('have.length', 1);
|
||||||
executionsTab.actions.createManualExecutions(4);
|
executionsTab.actions.createManualExecutions(4);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -582,7 +582,13 @@ describe('NDV', () => {
|
||||||
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib');
|
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib');
|
||||||
|
|
||||||
ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON');
|
ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON');
|
||||||
ndv.getters.outputDisplayMode().find('label').eq(1).click();
|
ndv.getters
|
||||||
|
.outputDisplayMode()
|
||||||
|
.find('label')
|
||||||
|
.eq(1)
|
||||||
|
.scrollIntoView()
|
||||||
|
.should('be.visible')
|
||||||
|
.click();
|
||||||
|
|
||||||
ndv.getters.outputDataContainer().find('.json-data').should('exist');
|
ndv.getters.outputDataContainer().find('.json-data').should('exist');
|
||||||
ndv.getters
|
ndv.getters
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const;
|
export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const;
|
||||||
export const RESOURCES = {
|
export const RESOURCES = {
|
||||||
|
annotationTag: [...DEFAULT_OPERATIONS] as const,
|
||||||
auditLogs: ['manage'] as const,
|
auditLogs: ['manage'] as const,
|
||||||
banner: ['dismiss'] as const,
|
banner: ['dismiss'] as const,
|
||||||
communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const,
|
communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const,
|
||||||
|
|
|
@ -10,6 +10,7 @@ export type ResourceScope<
|
||||||
|
|
||||||
export type WildcardScope = `${Resource}:*` | '*';
|
export type WildcardScope = `${Resource}:*` | '*';
|
||||||
|
|
||||||
|
export type AnnotationTagScope = ResourceScope<'annotationTag'>;
|
||||||
export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>;
|
export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>;
|
||||||
export type BannerScope = ResourceScope<'banner', 'dismiss'>;
|
export type BannerScope = ResourceScope<'banner', 'dismiss'>;
|
||||||
export type CommunityPackageScope = ResourceScope<
|
export type CommunityPackageScope = ResourceScope<
|
||||||
|
@ -44,6 +45,7 @@ export type WorkflowScope = ResourceScope<
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type Scope =
|
export type Scope =
|
||||||
|
| AnnotationTagScope
|
||||||
| AuditLogsScope
|
| AuditLogsScope
|
||||||
| BannerScope
|
| BannerScope
|
||||||
| CommunityPackageScope
|
| CommunityPackageScope
|
||||||
|
|
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),
|
database: path.resolve(Container.get(InstanceSettings).n8nFolder, sqliteConfig.database),
|
||||||
migrations: sqliteMigrations,
|
migrations: sqliteMigrations,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sqliteConfig.poolSize > 0) {
|
if (sqliteConfig.poolSize > 0) {
|
||||||
return {
|
return {
|
||||||
type: 'sqlite-pooled',
|
type: 'sqlite-pooled',
|
||||||
|
|
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 { ExecutionData } from './execution-data';
|
||||||
import type { ExecutionMetadata } from './execution-metadata';
|
import type { ExecutionMetadata } from './execution-metadata';
|
||||||
import { WorkflowEntity } from './workflow-entity';
|
import { WorkflowEntity } from './workflow-entity';
|
||||||
|
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index(['workflowId', 'id'])
|
@Index(['workflowId', 'id'])
|
||||||
|
@ -65,6 +66,9 @@ export class ExecutionEntity {
|
||||||
@OneToOne('ExecutionData', 'execution')
|
@OneToOne('ExecutionData', 'execution')
|
||||||
executionData: Relation<ExecutionData>;
|
executionData: Relation<ExecutionData>;
|
||||||
|
|
||||||
|
@OneToOne('ExecutionAnnotation', 'execution')
|
||||||
|
annotation?: Relation<ExecutionAnnotation>;
|
||||||
|
|
||||||
@ManyToOne('WorkflowEntity')
|
@ManyToOne('WorkflowEntity')
|
||||||
workflow: WorkflowEntity;
|
workflow: WorkflowEntity;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,13 +22,19 @@ import { WorkflowHistory } from './workflow-history';
|
||||||
import { Project } from './project';
|
import { Project } from './project';
|
||||||
import { ProjectRelation } from './project-relation';
|
import { ProjectRelation } from './project-relation';
|
||||||
import { InvalidAuthToken } from './invalid-auth-token';
|
import { InvalidAuthToken } from './invalid-auth-token';
|
||||||
|
import { AnnotationTagEntity } from './annotation-tag-entity';
|
||||||
|
import { AnnotationTagMapping } from './annotation-tag-mapping';
|
||||||
|
import { ExecutionAnnotation } from './execution-annotation';
|
||||||
|
|
||||||
export const entities = {
|
export const entities = {
|
||||||
|
AnnotationTagEntity,
|
||||||
|
AnnotationTagMapping,
|
||||||
AuthIdentity,
|
AuthIdentity,
|
||||||
AuthProviderSyncHistory,
|
AuthProviderSyncHistory,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
CredentialsEntity,
|
CredentialsEntity,
|
||||||
EventDestinations,
|
EventDestinations,
|
||||||
|
ExecutionAnnotation,
|
||||||
ExecutionEntity,
|
ExecutionEntity,
|
||||||
InstalledNodes,
|
InstalledNodes,
|
||||||
InstalledPackages,
|
InstalledPackages,
|
||||||
|
|
|
@ -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 { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
|
||||||
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
|
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
|
||||||
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
||||||
|
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -125,4 +126,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
AddConstraintToExecutionMetadata1720101653148,
|
AddConstraintToExecutionMetadata1720101653148,
|
||||||
CreateInvalidAuthTokenTable1723627610222,
|
CreateInvalidAuthTokenTable1723627610222,
|
||||||
RefactorExecutionIndices1723796243146,
|
RefactorExecutionIndices1723796243146,
|
||||||
|
CreateAnnotationTables1724753530828,
|
||||||
];
|
];
|
||||||
|
|
|
@ -61,6 +61,7 @@ import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-R
|
||||||
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
|
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
|
||||||
import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence';
|
import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence';
|
||||||
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
||||||
|
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -125,4 +126,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
FixExecutionMetadataSequence1721377157740,
|
FixExecutionMetadataSequence1721377157740,
|
||||||
CreateInvalidAuthTokenTable1723627610222,
|
CreateInvalidAuthTokenTable1723627610222,
|
||||||
RefactorExecutionIndices1723796243146,
|
RefactorExecutionIndices1723796243146,
|
||||||
|
CreateAnnotationTables1724753530828,
|
||||||
];
|
];
|
||||||
|
|
|
@ -58,6 +58,7 @@ import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActiv
|
||||||
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
|
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
|
||||||
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
|
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
|
||||||
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
||||||
|
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -119,6 +120,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
AddConstraintToExecutionMetadata1720101653148,
|
AddConstraintToExecutionMetadata1720101653148,
|
||||||
CreateInvalidAuthTokenTable1723627610222,
|
CreateInvalidAuthTokenTable1723627610222,
|
||||||
RefactorExecutionIndices1723796243146,
|
RefactorExecutionIndices1723796243146,
|
||||||
|
CreateAnnotationTables1724753530828,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -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 { Service } from 'typedi';
|
||||||
|
import pick from 'lodash/pick';
|
||||||
import {
|
import {
|
||||||
Brackets,
|
Brackets,
|
||||||
DataSource,
|
DataSource,
|
||||||
|
@ -21,14 +22,18 @@ import type {
|
||||||
} from '@n8n/typeorm';
|
} from '@n8n/typeorm';
|
||||||
import { parse, stringify } from 'flatted';
|
import { parse, stringify } from 'flatted';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import {
|
|
||||||
ApplicationError,
|
|
||||||
type ExecutionStatus,
|
|
||||||
type ExecutionSummary,
|
|
||||||
type IRunExecutionData,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { BinaryDataService } from 'n8n-core';
|
import { BinaryDataService } from 'n8n-core';
|
||||||
import { ExecutionCancelledError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
|
import {
|
||||||
|
ExecutionCancelledError,
|
||||||
|
ErrorReporterProxy as ErrorReporter,
|
||||||
|
ApplicationError,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
AnnotationVote,
|
||||||
|
ExecutionStatus,
|
||||||
|
ExecutionSummary,
|
||||||
|
IRunExecutionData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExecutionPayload,
|
ExecutionPayload,
|
||||||
|
@ -46,6 +51,9 @@ import { Logger } from '@/logger';
|
||||||
import type { ExecutionSummaries } from '@/executions/execution.types';
|
import type { ExecutionSummaries } from '@/executions/execution.types';
|
||||||
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
|
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
|
||||||
import { separate } from '@/utils';
|
import { separate } from '@/utils';
|
||||||
|
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
||||||
|
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
|
||||||
|
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
|
||||||
|
|
||||||
export interface IGetExecutionsQueryFilter {
|
export interface IGetExecutionsQueryFilter {
|
||||||
id?: FindOperator<string> | string;
|
id?: FindOperator<string> | string;
|
||||||
|
@ -201,10 +209,22 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private serializeAnnotation(annotation: ExecutionEntity['annotation']) {
|
||||||
|
if (!annotation) return null;
|
||||||
|
|
||||||
|
const { id, vote, tags } = annotation;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
vote,
|
||||||
|
tags: tags?.map((tag) => pick(tag, ['id', 'name'])) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async findSingleExecution(
|
async findSingleExecution(
|
||||||
id: string,
|
id: string,
|
||||||
options?: {
|
options?: {
|
||||||
includeData: true;
|
includeData: true;
|
||||||
|
includeAnnotation?: boolean;
|
||||||
unflattenData: true;
|
unflattenData: true;
|
||||||
where?: FindOptionsWhere<ExecutionEntity>;
|
where?: FindOptionsWhere<ExecutionEntity>;
|
||||||
},
|
},
|
||||||
|
@ -213,6 +233,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
id: string,
|
id: string,
|
||||||
options?: {
|
options?: {
|
||||||
includeData: true;
|
includeData: true;
|
||||||
|
includeAnnotation?: boolean;
|
||||||
unflattenData?: false | undefined;
|
unflattenData?: false | undefined;
|
||||||
where?: FindOptionsWhere<ExecutionEntity>;
|
where?: FindOptionsWhere<ExecutionEntity>;
|
||||||
},
|
},
|
||||||
|
@ -221,6 +242,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
id: string,
|
id: string,
|
||||||
options?: {
|
options?: {
|
||||||
includeData?: boolean;
|
includeData?: boolean;
|
||||||
|
includeAnnotation?: boolean;
|
||||||
unflattenData?: boolean;
|
unflattenData?: boolean;
|
||||||
where?: FindOptionsWhere<ExecutionEntity>;
|
where?: FindOptionsWhere<ExecutionEntity>;
|
||||||
},
|
},
|
||||||
|
@ -229,6 +251,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
id: string,
|
id: string,
|
||||||
options?: {
|
options?: {
|
||||||
includeData?: boolean;
|
includeData?: boolean;
|
||||||
|
includeAnnotation?: boolean;
|
||||||
unflattenData?: boolean;
|
unflattenData?: boolean;
|
||||||
where?: FindOptionsWhere<ExecutionEntity>;
|
where?: FindOptionsWhere<ExecutionEntity>;
|
||||||
},
|
},
|
||||||
|
@ -240,7 +263,16 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (options?.includeData) {
|
if (options?.includeData) {
|
||||||
findOptions.relations = ['executionData', 'metadata'];
|
findOptions.relations = { executionData: true, metadata: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeAnnotation) {
|
||||||
|
findOptions.relations = {
|
||||||
|
...findOptions.relations,
|
||||||
|
annotation: {
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const execution = await this.findOne(findOptions);
|
const execution = await this.findOne(findOptions);
|
||||||
|
@ -249,25 +281,21 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { executionData, metadata, ...rest } = execution;
|
const { executionData, metadata, annotation, ...rest } = execution;
|
||||||
|
const serializedAnnotation = this.serializeAnnotation(annotation);
|
||||||
|
|
||||||
if (options?.includeData && options?.unflattenData) {
|
return {
|
||||||
return {
|
...rest,
|
||||||
...rest,
|
...(options?.includeData && {
|
||||||
data: parse(execution.executionData.data) as IRunExecutionData,
|
data: options?.unflattenData
|
||||||
workflowData: execution.executionData.workflowData,
|
? (parse(executionData.data) as IRunExecutionData)
|
||||||
|
: executionData.data,
|
||||||
|
workflowData: executionData?.workflowData,
|
||||||
customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])),
|
customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])),
|
||||||
} as IExecutionResponse;
|
}),
|
||||||
} else if (options?.includeData) {
|
...(options?.includeAnnotation &&
|
||||||
return {
|
serializedAnnotation && { annotation: serializedAnnotation }),
|
||||||
...rest,
|
};
|
||||||
data: execution.executionData.data,
|
|
||||||
workflowData: execution.executionData.workflowData,
|
|
||||||
customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])),
|
|
||||||
} as IExecutionFlattedDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -410,6 +438,13 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h
|
const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h
|
||||||
const maxCount = config.getEnv('executions.pruneDataMaxCount');
|
const maxCount = config.getEnv('executions.pruneDataMaxCount');
|
||||||
|
|
||||||
|
// Sub-query to exclude executions having annotations
|
||||||
|
const annotatedExecutionsSubQuery = this.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.subQuery()
|
||||||
|
.select('annotation.executionId')
|
||||||
|
.from(ExecutionAnnotation, 'annotation');
|
||||||
|
|
||||||
// Find ids of all executions that were stopped longer that pruneDataMaxAge ago
|
// Find ids of all executions that were stopped longer that pruneDataMaxAge ago
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setHours(date.getHours() - maxAge);
|
date.setHours(date.getHours() - maxAge);
|
||||||
|
@ -420,12 +455,13 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
];
|
];
|
||||||
|
|
||||||
if (maxCount > 0) {
|
if (maxCount > 0) {
|
||||||
const executions = await this.find({
|
const executions = await this.createQueryBuilder('execution')
|
||||||
select: ['id'],
|
.select('execution.id')
|
||||||
skip: maxCount,
|
.where('execution.id NOT IN ' + annotatedExecutionsSubQuery.getQuery())
|
||||||
take: 1,
|
.skip(maxCount)
|
||||||
order: { id: 'DESC' },
|
.take(1)
|
||||||
});
|
.orderBy('execution.id', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
if (executions[0]) {
|
if (executions[0]) {
|
||||||
toPrune.push({ id: LessThanOrEqual(executions[0].id) });
|
toPrune.push({ id: LessThanOrEqual(executions[0].id) });
|
||||||
|
@ -442,6 +478,8 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
// Only mark executions as deleted if they are in an end state
|
// Only mark executions as deleted if they are in an end state
|
||||||
status: Not(In(['new', 'running', 'waiting'])),
|
status: Not(In(['new', 'running', 'waiting'])),
|
||||||
})
|
})
|
||||||
|
// Only mark executions as deleted if they are not annotated
|
||||||
|
.andWhere('id NOT IN ' + annotatedExecutionsSubQuery.getQuery())
|
||||||
.andWhere(
|
.andWhere(
|
||||||
new Brackets((qb) =>
|
new Brackets((qb) =>
|
||||||
countBasedWhere
|
countBasedWhere
|
||||||
|
@ -612,6 +650,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
},
|
},
|
||||||
includeData: true,
|
includeData: true,
|
||||||
unflattenData: true,
|
unflattenData: true,
|
||||||
|
includeAnnotation: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -622,6 +661,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
},
|
},
|
||||||
includeData: true,
|
includeData: true,
|
||||||
unflattenData: false,
|
unflattenData: false,
|
||||||
|
includeAnnotation: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -683,12 +723,80 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
stoppedAt: true,
|
stoppedAt: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private annotationFields = {
|
||||||
|
id: true,
|
||||||
|
vote: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function reduces duplicate rows in the raw result set of the query builder from *toQueryBuilderWithAnnotations*
|
||||||
|
* by merging the tags of the same execution annotation.
|
||||||
|
*/
|
||||||
|
private reduceExecutionsWithAnnotations(
|
||||||
|
rawExecutionsWithTags: Array<
|
||||||
|
ExecutionSummary & {
|
||||||
|
annotation_id: number;
|
||||||
|
annotation_vote: AnnotationVote;
|
||||||
|
annotation_tags_id: string;
|
||||||
|
annotation_tags_name: string;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
return rawExecutionsWithTags.reduce(
|
||||||
|
(
|
||||||
|
acc,
|
||||||
|
{
|
||||||
|
annotation_id: _,
|
||||||
|
annotation_vote: vote,
|
||||||
|
annotation_tags_id: tagId,
|
||||||
|
annotation_tags_name: tagName,
|
||||||
|
...row
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const existingExecution = acc.find((e) => e.id === row.id);
|
||||||
|
|
||||||
|
if (existingExecution) {
|
||||||
|
if (tagId) {
|
||||||
|
existingExecution.annotation = existingExecution.annotation ?? {
|
||||||
|
vote,
|
||||||
|
tags: [] as Array<{ id: string; name: string }>,
|
||||||
|
};
|
||||||
|
existingExecution.annotation.tags.push({ id: tagId, name: tagName });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
acc.push({
|
||||||
|
...row,
|
||||||
|
annotation: {
|
||||||
|
vote,
|
||||||
|
tags: tagId ? [{ id: tagId, name: tagName }] : [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[] as ExecutionSummary[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async findManyByRangeQuery(query: ExecutionSummaries.RangeQuery): Promise<ExecutionSummary[]> {
|
async findManyByRangeQuery(query: ExecutionSummaries.RangeQuery): Promise<ExecutionSummary[]> {
|
||||||
if (query?.accessibleWorkflowIds?.length === 0) {
|
if (query?.accessibleWorkflowIds?.length === 0) {
|
||||||
throw new ApplicationError('Expected accessible workflow IDs');
|
throw new ApplicationError('Expected accessible workflow IDs');
|
||||||
}
|
}
|
||||||
|
|
||||||
const executions: ExecutionSummary[] = await this.toQueryBuilder(query).getRawMany();
|
// Due to performance reasons, we use custom query builder with raw SQL.
|
||||||
|
// IMPORTANT: it produces duplicate rows for executions with multiple tags, which we need to reduce manually
|
||||||
|
const qb = this.toQueryBuilderWithAnnotations(query);
|
||||||
|
|
||||||
|
const rawExecutionsWithTags: Array<
|
||||||
|
ExecutionSummary & {
|
||||||
|
annotation_id: number;
|
||||||
|
annotation_vote: AnnotationVote;
|
||||||
|
annotation_tags_id: string;
|
||||||
|
annotation_tags_name: string;
|
||||||
|
}
|
||||||
|
> = await qb.getRawMany();
|
||||||
|
|
||||||
|
const executions = this.reduceExecutionsWithAnnotations(rawExecutionsWithTags);
|
||||||
|
|
||||||
return executions.map((execution) => this.toSummary(execution));
|
return executions.map((execution) => this.toSummary(execution));
|
||||||
}
|
}
|
||||||
|
@ -764,6 +872,8 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
startedBefore,
|
startedBefore,
|
||||||
startedAfter,
|
startedAfter,
|
||||||
metadata,
|
metadata,
|
||||||
|
annotationTags,
|
||||||
|
vote,
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const fields = Object.keys(this.summaryFields)
|
const fields = Object.keys(this.summaryFields)
|
||||||
|
@ -812,9 +922,62 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
qb.setParameter('value', value);
|
qb.setParameter('value', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (annotationTags?.length || vote) {
|
||||||
|
// If there is a filter by one or multiple tags or by vote - we need to join the annotations table
|
||||||
|
qb.innerJoin('execution.annotation', 'annotation');
|
||||||
|
|
||||||
|
// Add an inner join for each tag
|
||||||
|
if (annotationTags?.length) {
|
||||||
|
for (let index = 0; index < annotationTags.length; index++) {
|
||||||
|
qb.innerJoin(
|
||||||
|
AnnotationTagMapping,
|
||||||
|
`atm_${index}`,
|
||||||
|
`atm_${index}.annotationId = annotation.id AND atm_${index}.tagId = :tagId_${index}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
qb.setParameter(`tagId_${index}`, annotationTags[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filter by vote
|
||||||
|
if (vote) {
|
||||||
|
qb.andWhere('annotation.vote = :vote', { vote });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is used to add the annotation fields to the executions query
|
||||||
|
* It uses original query builder as a subquery and adds the annotation fields to it
|
||||||
|
* IMPORTANT: Query made with this query builder fetches duplicate execution rows for each tag,
|
||||||
|
* this is intended, as we are working with raw query.
|
||||||
|
* The duplicates are reduced in the *reduceExecutionsWithAnnotations* method.
|
||||||
|
*/
|
||||||
|
private toQueryBuilderWithAnnotations(query: ExecutionSummaries.Query) {
|
||||||
|
const annotationFields = Object.keys(this.annotationFields).map(
|
||||||
|
(key) => `annotation.${key} AS "annotation_${key}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subQuery = this.toQueryBuilder(query).addSelect(annotationFields);
|
||||||
|
|
||||||
|
// Ensure the join with annotations is made only once
|
||||||
|
// It might be already present as an inner join if the query includes filter by annotation tags
|
||||||
|
// If not, it must be added as a left join
|
||||||
|
if (!subQuery.expressionMap.joinAttributes.some((join) => join.alias.name === 'annotation')) {
|
||||||
|
subQuery.leftJoin('execution.annotation', 'annotation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select(['e.*', 'ate.id AS "annotation_tags_id"', 'ate.name AS "annotation_tags_name"'])
|
||||||
|
.from(`(${subQuery.getQuery()})`, 'e')
|
||||||
|
.setParameters(subQuery.getParameters())
|
||||||
|
.leftJoin(AnnotationTagMapping, 'atm', 'atm.annotationId = e.annotation_id')
|
||||||
|
.leftJoin(AnnotationTagEntity, 'ate', 'ate.id = atm.tagId');
|
||||||
|
}
|
||||||
|
|
||||||
async getAllIds() {
|
async getAllIds() {
|
||||||
const executions = await this.find({ select: ['id'], order: { id: 'ASC' } });
|
const executions = await this.find({ select: ['id'], order: { id: 'ASC' } });
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,8 @@ describe('ExecutionService', () => {
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
activeExecutions,
|
activeExecutions,
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
executionRepository,
|
executionRepository,
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { Container, Service } from 'typedi';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { validate as jsonSchemaValidate } from 'jsonschema';
|
import { validate as jsonSchemaValidate } from 'jsonschema';
|
||||||
import type {
|
import type {
|
||||||
IWorkflowBase,
|
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
|
ExecutionStatus,
|
||||||
INode,
|
INode,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
|
IWorkflowBase,
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
ExecutionStatus,
|
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
ApplicationError,
|
ApplicationError,
|
||||||
|
@ -41,6 +41,8 @@ import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.err
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||||
|
import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository';
|
||||||
|
import { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository';
|
||||||
|
|
||||||
export const schemaGetExecutionsQueryFilter = {
|
export const schemaGetExecutionsQueryFilter = {
|
||||||
$id: '/IGetExecutionsQueryFilter',
|
$id: '/IGetExecutionsQueryFilter',
|
||||||
|
@ -60,6 +62,8 @@ export const schemaGetExecutionsQueryFilter = {
|
||||||
metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } },
|
metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } },
|
||||||
startedAfter: { type: 'date-time' },
|
startedAfter: { type: 'date-time' },
|
||||||
startedBefore: { type: 'date-time' },
|
startedBefore: { type: 'date-time' },
|
||||||
|
annotationTags: { type: 'array', items: { type: 'string' } },
|
||||||
|
vote: { type: 'string' },
|
||||||
},
|
},
|
||||||
$defs: {
|
$defs: {
|
||||||
metadata: {
|
metadata: {
|
||||||
|
@ -85,6 +89,8 @@ export class ExecutionService {
|
||||||
private readonly globalConfig: GlobalConfig,
|
private readonly globalConfig: GlobalConfig,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly activeExecutions: ActiveExecutions,
|
private readonly activeExecutions: ActiveExecutions,
|
||||||
|
private readonly executionAnnotationRepository: ExecutionAnnotationRepository,
|
||||||
|
private readonly annotationTagMappingRepository: AnnotationTagMappingRepository,
|
||||||
private readonly executionRepository: ExecutionRepository,
|
private readonly executionRepository: ExecutionRepository,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
|
@ -96,7 +102,7 @@ export class ExecutionService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findOne(
|
async findOne(
|
||||||
req: ExecutionRequest.GetOne,
|
req: ExecutionRequest.GetOne | ExecutionRequest.Update,
|
||||||
sharedWorkflowIds: string[],
|
sharedWorkflowIds: string[],
|
||||||
): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> {
|
): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> {
|
||||||
if (!sharedWorkflowIds.length) return undefined;
|
if (!sharedWorkflowIds.length) return undefined;
|
||||||
|
@ -495,4 +501,42 @@ export class ExecutionService {
|
||||||
s.scopes = scopes[s.workflowId] ?? [];
|
s.scopes = scopes[s.workflowId] ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async annotate(
|
||||||
|
executionId: string,
|
||||||
|
updateData: ExecutionRequest.ExecutionUpdatePayload,
|
||||||
|
sharedWorkflowIds: string[],
|
||||||
|
) {
|
||||||
|
// Check if user can access the execution
|
||||||
|
const execution = await this.executionRepository.findIfAccessible(
|
||||||
|
executionId,
|
||||||
|
sharedWorkflowIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!execution) {
|
||||||
|
this.logger.info('Attempt to read execution was blocked due to insufficient permissions', {
|
||||||
|
executionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new NotFoundError('Execution not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update execution annotation
|
||||||
|
await this.executionAnnotationRepository.upsert(
|
||||||
|
{ execution: { id: executionId }, vote: updateData.vote },
|
||||||
|
['execution'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upsert behavior differs for Postgres, MySQL and sqlite,
|
||||||
|
// so we need to fetch the annotation to get the ID
|
||||||
|
const annotation = await this.executionAnnotationRepository.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
execution: { id: executionId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateData.tags) {
|
||||||
|
await this.annotationTagMappingRepository.overwriteTags(annotation.id, updateData.tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||||
import type { AuthenticatedRequest } from '@/requests';
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
import type {
|
import type {
|
||||||
|
AnnotationVote,
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
ExecutionSummary,
|
ExecutionSummary,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
|
@ -34,6 +35,11 @@ export declare namespace ExecutionRequest {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExecutionUpdatePayload = {
|
||||||
|
tags?: string[];
|
||||||
|
vote?: AnnotationVote | null;
|
||||||
|
};
|
||||||
|
|
||||||
type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & {
|
type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & {
|
||||||
rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params
|
rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params
|
||||||
};
|
};
|
||||||
|
@ -45,6 +51,8 @@ export declare namespace ExecutionRequest {
|
||||||
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>;
|
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>;
|
||||||
|
|
||||||
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;
|
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;
|
||||||
|
|
||||||
|
type Update = AuthenticatedRequest<RouteParams.ExecutionId, {}, ExecutionUpdatePayload, {}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace ExecutionSummaries {
|
export namespace ExecutionSummaries {
|
||||||
|
@ -69,6 +77,8 @@ export namespace ExecutionSummaries {
|
||||||
metadata: Array<{ key: string; value: string }>;
|
metadata: Array<{ key: string; value: string }>;
|
||||||
startedAfter: string;
|
startedAfter: string;
|
||||||
startedBefore: string;
|
startedBefore: string;
|
||||||
|
annotationTags: string[]; // tag IDs
|
||||||
|
vote: AnnotationVote;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type AccessFields = {
|
type AccessFields = {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { ExecutionRequest, type ExecutionSummaries } from './execution.types';
|
import { ExecutionRequest, type ExecutionSummaries } from './execution.types';
|
||||||
import { ExecutionService } from './execution.service';
|
import { ExecutionService } from './execution.service';
|
||||||
import { Get, Post, RestController } from '@/decorators';
|
import { validateExecutionUpdatePayload } from './validation';
|
||||||
|
import { Get, Patch, Post, RestController } from '@/decorators';
|
||||||
import { EnterpriseExecutionsService } from './execution.service.ee';
|
import { EnterpriseExecutionsService } from './execution.service.ee';
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||||
|
@ -47,7 +48,10 @@ export class ExecutionsController {
|
||||||
|
|
||||||
query.accessibleWorkflowIds = accessibleWorkflowIds;
|
query.accessibleWorkflowIds = accessibleWorkflowIds;
|
||||||
|
|
||||||
if (!this.license.isAdvancedExecutionFiltersEnabled()) delete query.metadata;
|
if (!this.license.isAdvancedExecutionFiltersEnabled()) {
|
||||||
|
delete query.metadata;
|
||||||
|
delete query.annotationTags;
|
||||||
|
}
|
||||||
|
|
||||||
const noStatus = !query.status || query.status.length === 0;
|
const noStatus = !query.status || query.status.length === 0;
|
||||||
const noRange = !query.range.lastId || !query.range.firstId;
|
const noRange = !query.range.lastId || !query.range.firstId;
|
||||||
|
@ -110,4 +114,23 @@ export class ExecutionsController {
|
||||||
|
|
||||||
return await this.executionService.delete(req, workflowIds);
|
return await this.executionService.delete(req, workflowIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('/:id')
|
||||||
|
async update(req: ExecutionRequest.Update) {
|
||||||
|
if (!isPositiveInteger(req.params.id)) {
|
||||||
|
throw new BadRequestError('Execution ID is not a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:read');
|
||||||
|
|
||||||
|
// Fail fast if no workflows are accessible
|
||||||
|
if (workflowIds.length === 0) throw new NotFoundError('Execution not found');
|
||||||
|
|
||||||
|
const { body: payload } = req;
|
||||||
|
const validatedPayload = validateExecutionUpdatePayload(payload);
|
||||||
|
|
||||||
|
await this.executionService.annotate(req.params.id, validatedPayload, workflowIds);
|
||||||
|
|
||||||
|
return await this.executionService.findOne(req, workflowIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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 { validate } from 'class-validator';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||||
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
||||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import type {
|
import type {
|
||||||
|
@ -16,6 +17,7 @@ export async function validateEntity(
|
||||||
| WorkflowEntity
|
| WorkflowEntity
|
||||||
| CredentialsEntity
|
| CredentialsEntity
|
||||||
| TagEntity
|
| TagEntity
|
||||||
|
| AnnotationTagEntity
|
||||||
| User
|
| User
|
||||||
| UserUpdatePayload
|
| UserUpdatePayload
|
||||||
| UserRoleChangePayload
|
| UserRoleChangePayload
|
||||||
|
|
|
@ -31,6 +31,7 @@ import type { WorkflowExecute } from 'n8n-core';
|
||||||
|
|
||||||
import type PCancelable from 'p-cancelable';
|
import type PCancelable from 'p-cancelable';
|
||||||
|
|
||||||
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
||||||
import type { AuthProviderType } from '@/databases/entities/auth-identity';
|
import type { AuthProviderType } from '@/databases/entities/auth-identity';
|
||||||
import type { SharedCredentials } from '@/databases/entities/shared-credentials';
|
import type { SharedCredentials } from '@/databases/entities/shared-credentials';
|
||||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||||
|
@ -57,9 +58,12 @@ export interface ICredentialsOverwrite {
|
||||||
// tags
|
// tags
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
export interface ITagToImport {
|
export interface ITagBase {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITagToImport extends ITagBase {
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
@ -68,8 +72,13 @@ export type UsageCount = {
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ITagWithCountDb = Pick<TagEntity, 'id' | 'name' | 'createdAt' | 'updatedAt'> &
|
export type ITagDb = Pick<TagEntity, 'id' | 'name' | 'createdAt' | 'updatedAt'>;
|
||||||
UsageCount;
|
|
||||||
|
export type ITagWithCountDb = ITagDb & UsageCount;
|
||||||
|
|
||||||
|
export type IAnnotationTagDb = Pick<AnnotationTagEntity, 'id' | 'name' | 'createdAt' | 'updatedAt'>;
|
||||||
|
|
||||||
|
export type IAnnotationTagWithCountDb = IAnnotationTagDb & UsageCount;
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// workflows
|
// workflows
|
||||||
|
@ -145,6 +154,9 @@ export interface IExecutionResponse extends IExecutionBase {
|
||||||
retrySuccessId?: string;
|
retrySuccessId?: string;
|
||||||
workflowData: IWorkflowBase | WorkflowWithSharingsAndCredentials;
|
workflowData: IWorkflowBase | WorkflowWithSharingsAndCredentials;
|
||||||
customData: Record<string, string>;
|
customData: Record<string, string>;
|
||||||
|
annotation: {
|
||||||
|
tags: ITagBase[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatted data to save memory when saving in database or transferring
|
// Flatted data to save memory when saving in database or transferring
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
|
|
||||||
export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||||
|
'annotationTag:create',
|
||||||
|
'annotationTag:read',
|
||||||
|
'annotationTag:update',
|
||||||
|
'annotationTag:delete',
|
||||||
|
'annotationTag:list',
|
||||||
'auditLogs:manage',
|
'auditLogs:manage',
|
||||||
'banner:dismiss',
|
'banner:dismiss',
|
||||||
'credential:create',
|
'credential:create',
|
||||||
|
@ -75,6 +80,11 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||||
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
|
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
|
||||||
|
|
||||||
export const GLOBAL_MEMBER_SCOPES: Scope[] = [
|
export const GLOBAL_MEMBER_SCOPES: Scope[] = [
|
||||||
|
'annotationTag:create',
|
||||||
|
'annotationTag:read',
|
||||||
|
'annotationTag:update',
|
||||||
|
'annotationTag:delete',
|
||||||
|
'annotationTag:list',
|
||||||
'eventBusDestination:list',
|
'eventBusDestination:list',
|
||||||
'eventBusDestination:test',
|
'eventBusDestination:test',
|
||||||
'tag:create',
|
'tag:create',
|
||||||
|
|
|
@ -448,6 +448,17 @@ export declare namespace TagsRequest {
|
||||||
type Delete = AuthenticatedRequest<{ id: string }>;
|
type Delete = AuthenticatedRequest<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// /annotation-tags
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
export declare namespace AnnotationTagsRequest {
|
||||||
|
type GetAll = AuthenticatedRequest<{}, {}, {}, { withUsageCount: string }>;
|
||||||
|
type Create = AuthenticatedRequest<{}, {}, { name: string }>;
|
||||||
|
type Update = AuthenticatedRequest<{ id: string }, {}, { name: string }>;
|
||||||
|
type Delete = AuthenticatedRequest<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// /nodes
|
// /nodes
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -38,6 +38,7 @@ import { OrchestrationService } from '@/services/orchestration.service';
|
||||||
import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay';
|
import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay';
|
||||||
|
|
||||||
import '@/controllers/active-workflows.controller';
|
import '@/controllers/active-workflows.controller';
|
||||||
|
import '@/controllers/annotation-tags.controller';
|
||||||
import '@/controllers/auth.controller';
|
import '@/controllers/auth.controller';
|
||||||
import '@/controllers/binary-data.controller';
|
import '@/controllers/binary-data.controller';
|
||||||
import '@/controllers/curl.controller';
|
import '@/controllers/curl.controller';
|
||||||
|
|
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 { mock } from 'jest-mock-extended';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
import { createWorkflow } from './shared/db/workflows';
|
import { createWorkflow } from './shared/db/workflows';
|
||||||
import { createExecution } from './shared/db/executions';
|
import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions';
|
||||||
import * as testDb from './shared/test-db';
|
import * as testDb from './shared/test-db';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import type { ExecutionSummaries } from '@/executions/execution.types';
|
import type { ExecutionSummaries } from '@/executions/execution.types';
|
||||||
|
@ -19,6 +19,8 @@ describe('ExecutionService', () => {
|
||||||
executionRepository = Container.get(ExecutionRepository);
|
executionRepository = Container.get(ExecutionRepository);
|
||||||
|
|
||||||
executionService = new ExecutionService(
|
executionService = new ExecutionService(
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
|
@ -70,6 +72,10 @@ describe('ExecutionService', () => {
|
||||||
waitTill: null,
|
waitTill: null,
|
||||||
retrySuccessId: null,
|
retrySuccessId: null,
|
||||||
workflowName: expect.any(String),
|
workflowName: expect.any(String),
|
||||||
|
annotation: {
|
||||||
|
tags: expect.arrayContaining([]),
|
||||||
|
vote: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(output.count).toBe(2);
|
expect(output.count).toBe(2);
|
||||||
|
@ -462,4 +468,201 @@ describe('ExecutionService', () => {
|
||||||
expect(results[1].status).toBe('running');
|
expect(results[1].status).toBe('running');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('annotation', () => {
|
||||||
|
const summaryShape = {
|
||||||
|
id: expect.any(String),
|
||||||
|
workflowId: expect.any(String),
|
||||||
|
mode: expect.any(String),
|
||||||
|
retryOf: null,
|
||||||
|
status: expect.any(String),
|
||||||
|
startedAt: expect.any(String),
|
||||||
|
stoppedAt: expect.any(String),
|
||||||
|
waitTill: null,
|
||||||
|
retrySuccessId: null,
|
||||||
|
workflowName: expect.any(String),
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await testDb.truncate(['AnnotationTag', 'ExecutionAnnotation']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should add and retrieve annotation', async () => {
|
||||||
|
const workflow = await createWorkflow();
|
||||||
|
|
||||||
|
const execution1 = await createExecution({ status: 'success' }, workflow);
|
||||||
|
const execution2 = await createExecution({ status: 'success' }, workflow);
|
||||||
|
|
||||||
|
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
|
||||||
|
|
||||||
|
await annotateExecution(
|
||||||
|
execution1.id,
|
||||||
|
{ vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] },
|
||||||
|
[workflow.id],
|
||||||
|
);
|
||||||
|
await annotateExecution(execution2.id, { vote: 'down', tags: [annotationTags[2].id] }, [
|
||||||
|
workflow.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query: ExecutionSummaries.RangeQuery = {
|
||||||
|
kind: 'range',
|
||||||
|
status: ['success'],
|
||||||
|
range: { limit: 20 },
|
||||||
|
accessibleWorkflowIds: [workflow.id],
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await executionService.findRangeWithCount(query);
|
||||||
|
|
||||||
|
expect(output.count).toBe(2);
|
||||||
|
expect(output.estimated).toBe(false);
|
||||||
|
expect(output.results).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
...summaryShape,
|
||||||
|
annotation: {
|
||||||
|
tags: [expect.objectContaining({ name: 'tag3' })],
|
||||||
|
vote: 'down',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...summaryShape,
|
||||||
|
annotation: {
|
||||||
|
tags: [
|
||||||
|
expect.objectContaining({ name: 'tag1' }),
|
||||||
|
expect.objectContaining({ name: 'tag2' }),
|
||||||
|
],
|
||||||
|
vote: 'up',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update annotation', async () => {
|
||||||
|
const workflow = await createWorkflow();
|
||||||
|
|
||||||
|
const execution = await createExecution({ status: 'success' }, workflow);
|
||||||
|
|
||||||
|
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
|
||||||
|
|
||||||
|
await annotateExecution(execution.id, { vote: 'up', tags: [annotationTags[0].id] }, [
|
||||||
|
workflow.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await annotateExecution(execution.id, { vote: 'down', tags: [annotationTags[1].id] }, [
|
||||||
|
workflow.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query: ExecutionSummaries.RangeQuery = {
|
||||||
|
kind: 'range',
|
||||||
|
status: ['success'],
|
||||||
|
range: { limit: 20 },
|
||||||
|
accessibleWorkflowIds: [workflow.id],
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await executionService.findRangeWithCount(query);
|
||||||
|
|
||||||
|
expect(output.count).toBe(1);
|
||||||
|
expect(output.estimated).toBe(false);
|
||||||
|
expect(output.results).toEqual([
|
||||||
|
{
|
||||||
|
...summaryShape,
|
||||||
|
annotation: {
|
||||||
|
tags: [expect.objectContaining({ name: 'tag2' })],
|
||||||
|
vote: 'down',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by annotation tags', async () => {
|
||||||
|
const workflow = await createWorkflow();
|
||||||
|
|
||||||
|
const executions = await Promise.all([
|
||||||
|
createExecution({ status: 'success' }, workflow),
|
||||||
|
createExecution({ status: 'success' }, workflow),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
|
||||||
|
|
||||||
|
await annotateExecution(
|
||||||
|
executions[0].id,
|
||||||
|
{ vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] },
|
||||||
|
[workflow.id],
|
||||||
|
);
|
||||||
|
await annotateExecution(executions[1].id, { vote: 'down', tags: [annotationTags[2].id] }, [
|
||||||
|
workflow.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query: ExecutionSummaries.RangeQuery = {
|
||||||
|
kind: 'range',
|
||||||
|
status: ['success'],
|
||||||
|
range: { limit: 20 },
|
||||||
|
accessibleWorkflowIds: [workflow.id],
|
||||||
|
annotationTags: [annotationTags[0].id],
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await executionService.findRangeWithCount(query);
|
||||||
|
|
||||||
|
expect(output.count).toBe(1);
|
||||||
|
expect(output.estimated).toBe(false);
|
||||||
|
expect(output.results).toEqual([
|
||||||
|
{
|
||||||
|
...summaryShape,
|
||||||
|
annotation: {
|
||||||
|
tags: [
|
||||||
|
expect.objectContaining({ name: 'tag1' }),
|
||||||
|
expect.objectContaining({ name: 'tag2' }),
|
||||||
|
],
|
||||||
|
vote: 'up',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by annotation vote', async () => {
|
||||||
|
const workflow = await createWorkflow();
|
||||||
|
|
||||||
|
const executions = await Promise.all([
|
||||||
|
createExecution({ status: 'success' }, workflow),
|
||||||
|
createExecution({ status: 'success' }, workflow),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']);
|
||||||
|
|
||||||
|
await annotateExecution(
|
||||||
|
executions[0].id,
|
||||||
|
{ vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] },
|
||||||
|
[workflow.id],
|
||||||
|
);
|
||||||
|
await annotateExecution(executions[1].id, { vote: 'down', tags: [annotationTags[2].id] }, [
|
||||||
|
workflow.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query: ExecutionSummaries.RangeQuery = {
|
||||||
|
kind: 'range',
|
||||||
|
status: ['success'],
|
||||||
|
range: { limit: 20 },
|
||||||
|
accessibleWorkflowIds: [workflow.id],
|
||||||
|
vote: 'up',
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await executionService.findRangeWithCount(query);
|
||||||
|
|
||||||
|
expect(output.count).toBe(1);
|
||||||
|
expect(output.estimated).toBe(false);
|
||||||
|
expect(output.results).toEqual([
|
||||||
|
{
|
||||||
|
...summaryShape,
|
||||||
|
annotation: {
|
||||||
|
tags: [
|
||||||
|
expect.objectContaining({ name: 'tag1' }),
|
||||||
|
expect.objectContaining({ name: 'tag2' }),
|
||||||
|
],
|
||||||
|
vote: 'up',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,11 @@ import { Logger } from '@/logger';
|
||||||
|
|
||||||
import { mockInstance } from '../shared/mocking';
|
import { mockInstance } from '../shared/mocking';
|
||||||
import { createWorkflow } from './shared/db/workflows';
|
import { createWorkflow } from './shared/db/workflows';
|
||||||
import { createExecution, createSuccessfulExecution } from './shared/db/executions';
|
import {
|
||||||
|
annotateExecution,
|
||||||
|
createExecution,
|
||||||
|
createSuccessfulExecution,
|
||||||
|
} from './shared/db/executions';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
describe('softDeleteOnPruningCycle()', () => {
|
describe('softDeleteOnPruningCycle()', () => {
|
||||||
|
@ -40,7 +44,7 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await testDb.truncate(['Execution']);
|
await testDb.truncate(['Execution', 'ExecutionAnnotation']);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -138,6 +142,25 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
expect.objectContaining({ id: executions[1].id, deletedAt: null }),
|
expect.objectContaining({ id: executions[1].id, deletedAt: null }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should not prune annotated executions', async () => {
|
||||||
|
const executions = [
|
||||||
|
await createSuccessfulExecution(workflow),
|
||||||
|
await createSuccessfulExecution(workflow),
|
||||||
|
await createSuccessfulExecution(workflow),
|
||||||
|
];
|
||||||
|
|
||||||
|
await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]);
|
||||||
|
|
||||||
|
await pruningService.softDeleteOnPruningCycle();
|
||||||
|
|
||||||
|
const result = await findAllExecutions();
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: executions[0].id, deletedAt: null }),
|
||||||
|
expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }),
|
||||||
|
expect.objectContaining({ id: executions[2].id, deletedAt: null }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when EXECUTIONS_DATA_MAX_AGE is set', () => {
|
describe('when EXECUTIONS_DATA_MAX_AGE is set', () => {
|
||||||
|
@ -226,5 +249,33 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
expect.objectContaining({ id: executions[1].id, deletedAt: null }),
|
expect.objectContaining({ id: executions[1].id, deletedAt: null }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should not prune annotated executions', async () => {
|
||||||
|
const executions = [
|
||||||
|
await createExecution(
|
||||||
|
{ finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' },
|
||||||
|
workflow,
|
||||||
|
),
|
||||||
|
await createExecution(
|
||||||
|
{ finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' },
|
||||||
|
workflow,
|
||||||
|
),
|
||||||
|
await createExecution(
|
||||||
|
{ finished: true, startedAt: now, stoppedAt: now, status: 'success' },
|
||||||
|
workflow,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]);
|
||||||
|
|
||||||
|
await pruningService.softDeleteOnPruningCycle();
|
||||||
|
|
||||||
|
const result = await findAllExecutions();
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: executions[0].id, deletedAt: null }),
|
||||||
|
expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }),
|
||||||
|
expect.objectContaining({ id: executions[2].id, deletedAt: null }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,6 +5,13 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository';
|
import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository';
|
||||||
import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository';
|
import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository';
|
||||||
|
import { ExecutionService } from '@/executions/execution.service';
|
||||||
|
import type { AnnotationVote } from 'n8n-workflow';
|
||||||
|
import { mockInstance } from '@test/mocking';
|
||||||
|
import { Telemetry } from '@/telemetry';
|
||||||
|
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository';
|
||||||
|
|
||||||
|
mockInstance(Telemetry);
|
||||||
|
|
||||||
export async function createManyExecutions(
|
export async function createManyExecutions(
|
||||||
amount: number,
|
amount: number,
|
||||||
|
@ -85,6 +92,19 @@ export async function createWaitingExecution(workflow: WorkflowEntity) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function annotateExecution(
|
||||||
|
executionId: string,
|
||||||
|
annotation: { vote?: AnnotationVote | null; tags?: string[] },
|
||||||
|
sharedWorkflowIds: string[],
|
||||||
|
) {
|
||||||
|
await Container.get(ExecutionService).annotate(executionId, annotation, sharedWorkflowIds);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAllExecutions() {
|
export async function getAllExecutions() {
|
||||||
return await Container.get(ExecutionRepository).find();
|
return await Container.get(ExecutionRepository).find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createAnnotationTags(annotationTags: string[]) {
|
||||||
|
const tagRepository = Container.get(AnnotationTagRepository);
|
||||||
|
return await tagRepository.save(annotationTags.map((name) => tagRepository.create({ name })));
|
||||||
|
}
|
||||||
|
|
|
@ -48,11 +48,13 @@ export async function terminate() {
|
||||||
|
|
||||||
// Can't use `Object.keys(entities)` here because some entities have a `Entity` suffix, while the repositories don't
|
// Can't use `Object.keys(entities)` here because some entities have a `Entity` suffix, while the repositories don't
|
||||||
const repositories = [
|
const repositories = [
|
||||||
|
'AnnotationTag',
|
||||||
'AuthIdentity',
|
'AuthIdentity',
|
||||||
'AuthProviderSyncHistory',
|
'AuthProviderSyncHistory',
|
||||||
'Credentials',
|
'Credentials',
|
||||||
'EventDestinations',
|
'EventDestinations',
|
||||||
'Execution',
|
'Execution',
|
||||||
|
'ExecutionAnnotation',
|
||||||
'ExecutionData',
|
'ExecutionData',
|
||||||
'ExecutionMetadata',
|
'ExecutionMetadata',
|
||||||
'InstalledNodes',
|
'InstalledNodes',
|
||||||
|
|
|
@ -26,6 +26,7 @@ type EndpointGroup =
|
||||||
| 'eventBus'
|
| 'eventBus'
|
||||||
| 'license'
|
| 'license'
|
||||||
| 'variables'
|
| 'variables'
|
||||||
|
| 'annotationTags'
|
||||||
| 'tags'
|
| 'tags'
|
||||||
| 'externalSecrets'
|
| 'externalSecrets'
|
||||||
| 'mfa'
|
| 'mfa'
|
||||||
|
|
|
@ -122,6 +122,10 @@ export const setupTestServer = ({
|
||||||
if (endpointGroups.length) {
|
if (endpointGroups.length) {
|
||||||
for (const group of endpointGroups) {
|
for (const group of endpointGroups) {
|
||||||
switch (group) {
|
switch (group) {
|
||||||
|
case 'annotationTags':
|
||||||
|
await import('@/controllers/annotation-tags.controller');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'credentials':
|
case 'credentials':
|
||||||
await import('@/credentials/credentials.controller');
|
await import('@/credentials/credentials.controller');
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -100,6 +100,7 @@ defineExpose({
|
||||||
focus,
|
focus,
|
||||||
blur,
|
blur,
|
||||||
focusOnInput,
|
focusOnInput,
|
||||||
|
innerSelect,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
text: string;
|
text: string;
|
||||||
|
clickable?: boolean;
|
||||||
}
|
}
|
||||||
defineOptions({ name: 'N8nTag' });
|
defineOptions({ name: 'N8nTag' });
|
||||||
defineProps<TagProps>();
|
withDefaults(defineProps<TagProps>(), {
|
||||||
|
clickable: true,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span :class="['n8n-tag', $style.tag]" v-bind="$attrs">
|
<span :class="['n8n-tag', $style.tag, { [$style.clickable]: clickable }]" v-bind="$attrs">
|
||||||
{{ text }}
|
{{ text }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
@ -20,11 +23,15 @@ defineProps<TagProps>();
|
||||||
background-color: var(--color-background-base);
|
background-color: var(--color-background-base);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&.clickable {
|
||||||
background-color: var(--color-background-medium);
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-medium);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import N8nTag from '../N8nTag';
|
||||||
import N8nLink from '../N8nLink';
|
import N8nLink from '../N8nLink';
|
||||||
import { useI18n } from '../../composables/useI18n';
|
import { useI18n } from '../../composables/useI18n';
|
||||||
|
|
||||||
export interface ITag {
|
interface ITag {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ interface TagsProp {
|
||||||
tags?: ITag[];
|
tags?: ITag[];
|
||||||
truncate?: boolean;
|
truncate?: boolean;
|
||||||
truncateAt?: number;
|
truncateAt?: number;
|
||||||
|
clickable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({ name: 'N8nTags' });
|
defineOptions({ name: 'N8nTags' });
|
||||||
|
@ -20,6 +21,7 @@ const props = withDefaults(defineProps<TagsProp>(), {
|
||||||
tags: () => [],
|
tags: () => [],
|
||||||
truncate: false,
|
truncate: false,
|
||||||
truncateAt: 3,
|
truncateAt: 3,
|
||||||
|
clickable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -53,6 +55,7 @@ const onExpand = () => {
|
||||||
v-for="tag in visibleTags"
|
v-for="tag in visibleTags"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
:text="tag.name"
|
:text="tag.name"
|
||||||
|
:clickable="clickable"
|
||||||
@click="emit('click:tag', tag.id, $event)"
|
@click="emit('click:tag', tag.id, $event)"
|
||||||
/>
|
/>
|
||||||
<N8nLink
|
<N8nLink
|
||||||
|
|
|
@ -49,6 +49,7 @@ import type {
|
||||||
INodeCredentialsDetails,
|
INodeCredentialsDetails,
|
||||||
StartNodeData,
|
StartNodeData,
|
||||||
IPersonalizationSurveyAnswersV4,
|
IPersonalizationSurveyAnswersV4,
|
||||||
|
AnnotationVote,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { BulkCommand, Undoable } from '@/models/history';
|
import type { BulkCommand, Undoable } from '@/models/history';
|
||||||
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
||||||
|
@ -1554,12 +1555,16 @@ export type ExecutionFilterMetadata = {
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExecutionFilterVote = AnnotationVote | 'all';
|
||||||
|
|
||||||
export type ExecutionFilterType = {
|
export type ExecutionFilterType = {
|
||||||
status: string;
|
status: string;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
startDate: string | Date;
|
startDate: string | Date;
|
||||||
endDate: string | Date;
|
endDate: string | Date;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
annotationTags: string[];
|
||||||
|
vote: ExecutionFilterVote;
|
||||||
metadata: ExecutionFilterMetadata[];
|
metadata: ExecutionFilterMetadata[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1571,6 +1576,8 @@ export type ExecutionsQueryFilter = {
|
||||||
metadata?: Array<{ key: string; value: string }>;
|
metadata?: Array<{ key: string; value: string }>;
|
||||||
startedAfter?: string;
|
startedAfter?: string;
|
||||||
startedBefore?: string;
|
startedBefore?: string;
|
||||||
|
annotationTags?: string[];
|
||||||
|
vote?: ExecutionFilterVote;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SamlAttributeMapping = {
|
export type SamlAttributeMapping = {
|
||||||
|
|
|
@ -1,22 +1,32 @@
|
||||||
import type { IRestApiContext, ITag } from '@/Interface';
|
import type { IRestApiContext, ITag } from '@/Interface';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
|
|
||||||
export async function getTags(context: IRestApiContext, withUsageCount = false): Promise<ITag[]> {
|
type TagsApiEndpoint = '/tags' | '/annotation-tags';
|
||||||
return await makeRestApiRequest(context, 'GET', '/tags', { withUsageCount });
|
|
||||||
|
export interface ITagsApi {
|
||||||
|
getTags: (context: IRestApiContext, withUsageCount?: boolean) => Promise<ITag[]>;
|
||||||
|
createTag: (context: IRestApiContext, params: { name: string }) => Promise<ITag>;
|
||||||
|
updateTag: (context: IRestApiContext, id: string, params: { name: string }) => Promise<ITag>;
|
||||||
|
deleteTag: (context: IRestApiContext, id: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTag(context: IRestApiContext, params: { name: string }): Promise<ITag> {
|
export function createTagsApi(endpoint: TagsApiEndpoint): ITagsApi {
|
||||||
return await makeRestApiRequest(context, 'POST', '/tags', params);
|
return {
|
||||||
}
|
getTags: async (context: IRestApiContext, withUsageCount = false): Promise<ITag[]> => {
|
||||||
|
return await makeRestApiRequest(context, 'GET', endpoint, { withUsageCount });
|
||||||
export async function updateTag(
|
},
|
||||||
context: IRestApiContext,
|
createTag: async (context: IRestApiContext, params: { name: string }): Promise<ITag> => {
|
||||||
id: string,
|
return await makeRestApiRequest(context, 'POST', endpoint, params);
|
||||||
params: { name: string },
|
},
|
||||||
): Promise<ITag> {
|
updateTag: async (
|
||||||
return await makeRestApiRequest(context, 'PATCH', `/tags/${id}`, params);
|
context: IRestApiContext,
|
||||||
}
|
id: string,
|
||||||
|
params: { name: string },
|
||||||
export async function deleteTag(context: IRestApiContext, id: string): Promise<boolean> {
|
): Promise<ITag> => {
|
||||||
return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`);
|
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 { mapStores } from 'pinia';
|
||||||
import { MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
import { MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import TagsDropdown from '@/components/TagsDropdown.vue';
|
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
@ -16,7 +16,7 @@ import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'DuplicateWorkflow',
|
name: 'DuplicateWorkflow',
|
||||||
components: { TagsDropdown, Modal },
|
components: { WorkflowTagsDropdown, Modal },
|
||||||
props: ['modalName', 'isActive', 'data'],
|
props: ['modalName', 'isActive', 'data'],
|
||||||
setup() {
|
setup() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -167,7 +167,7 @@ export default defineComponent({
|
||||||
:placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')"
|
:placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')"
|
||||||
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
|
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
|
||||||
/>
|
/>
|
||||||
<TagsDropdown
|
<WorkflowTagsDropdown
|
||||||
v-if="settingsStore.areTagsEnabled"
|
v-if="settingsStore.areTagsEnabled"
|
||||||
ref="dropdown"
|
ref="dropdown"
|
||||||
v-model="currentTagIds"
|
v-model="currentTagIds"
|
||||||
|
|
|
@ -14,11 +14,11 @@ import {
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import ShortenName from '@/components/ShortenName.vue';
|
import ShortenName from '@/components/ShortenName.vue';
|
||||||
import TagsContainer from '@/components/TagsContainer.vue';
|
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
|
||||||
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
||||||
import WorkflowActivator from '@/components/WorkflowActivator.vue';
|
import WorkflowActivator from '@/components/WorkflowActivator.vue';
|
||||||
import SaveButton from '@/components/SaveButton.vue';
|
import SaveButton from '@/components/SaveButton.vue';
|
||||||
import TagsDropdown from '@/components/TagsDropdown.vue';
|
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
||||||
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
||||||
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
||||||
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
|
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
|
||||||
|
@ -631,7 +631,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
</BreakpointsObserver>
|
</BreakpointsObserver>
|
||||||
|
|
||||||
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
||||||
<TagsDropdown
|
<WorkflowTagsDropdown
|
||||||
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
|
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
|
||||||
ref="dropdown"
|
ref="dropdown"
|
||||||
v-model="appliedTagIds"
|
v-model="appliedTagIds"
|
||||||
|
@ -653,7 +653,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
+ {{ $locale.baseText('workflowDetails.addTag') }}
|
+ {{ $locale.baseText('workflowDetails.addTag') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<TagsContainer
|
<WorkflowTagsContainer
|
||||||
v-else
|
v-else
|
||||||
:key="workflow.id"
|
:key="workflow.id"
|
||||||
:tag-ids="workflowTagIds"
|
:tag-ids="workflowTagIds"
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
INVITE_USER_MODAL_KEY,
|
INVITE_USER_MODAL_KEY,
|
||||||
PERSONALIZATION_MODAL_KEY,
|
PERSONALIZATION_MODAL_KEY,
|
||||||
TAGS_MANAGER_MODAL_KEY,
|
TAGS_MANAGER_MODAL_KEY,
|
||||||
|
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||||
NPS_SURVEY_MODAL_KEY,
|
NPS_SURVEY_MODAL_KEY,
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
|
@ -46,7 +47,8 @@ import CredentialsSelectModal from '@/components/CredentialsSelectModal.vue';
|
||||||
import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
|
import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
|
||||||
import ModalRoot from '@/components/ModalRoot.vue';
|
import ModalRoot from '@/components/ModalRoot.vue';
|
||||||
import PersonalizationModal from '@/components/PersonalizationModal.vue';
|
import PersonalizationModal from '@/components/PersonalizationModal.vue';
|
||||||
import TagsManager from '@/components/TagsManager/TagsManager.vue';
|
import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue';
|
||||||
|
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.vue';
|
||||||
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
||||||
import NpsSurvey from '@/components/NpsSurvey.vue';
|
import NpsSurvey from '@/components/NpsSurvey.vue';
|
||||||
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
|
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
|
||||||
|
@ -105,7 +107,11 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="TAGS_MANAGER_MODAL_KEY">
|
<ModalRoot :name="TAGS_MANAGER_MODAL_KEY">
|
||||||
<TagsManager />
|
<WorkflowTagsManager />
|
||||||
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="ANNOTATION_TAGS_MANAGER_MODAL_KEY">
|
||||||
|
<AnnotationTagsManager />
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="VERSIONS_MODAL_KEY" :keep-alive="true">
|
<ModalRoot :name="VERSIONS_MODAL_KEY" :keep-alive="true">
|
||||||
|
|
|
@ -1,133 +1,121 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent, type ComponentInstance } from 'vue';
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
|
import type { ComponentInstance } from 'vue';
|
||||||
import type { ITag } from '@/Interface';
|
import type { ITag } from '@/Interface';
|
||||||
import IntersectionObserver from './IntersectionObserver.vue';
|
import IntersectionObserver from './IntersectionObserver.vue';
|
||||||
import IntersectionObserved from './IntersectionObserved.vue';
|
import IntersectionObserved from './IntersectionObserved.vue';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useTagsStore } from '@/stores/tags.store';
|
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
// random upper limit if none is set to minimize performance impact of observers
|
interface TagsContainerProps {
|
||||||
const DEFAULT_MAX_TAGS_LIMIT = 20;
|
tagIds: string[];
|
||||||
|
tagsById: { [id: string]: ITag };
|
||||||
interface TagEl extends ITag {
|
limit?: number;
|
||||||
hidden?: boolean;
|
clickable?: boolean;
|
||||||
title?: string;
|
responsive?: boolean;
|
||||||
isCount?: boolean;
|
hoverable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(defineProps<TagsContainerProps>(), {
|
||||||
name: 'TagsContainer',
|
limit: 20,
|
||||||
components: { IntersectionObserver, IntersectionObserved },
|
clickable: false,
|
||||||
props: {
|
responsive: false,
|
||||||
tagIds: {
|
hoverable: false,
|
||||||
type: Array as () => string[],
|
});
|
||||||
required: true,
|
|
||||||
},
|
const emit = defineEmits<{
|
||||||
limit: {
|
click: [tagId: string];
|
||||||
type: Number,
|
}>();
|
||||||
default: DEFAULT_MAX_TAGS_LIMIT,
|
|
||||||
},
|
// Data
|
||||||
clickable: Boolean,
|
const maxWidth = ref(320);
|
||||||
responsive: Boolean,
|
const intersectionEventBus = createEventBus();
|
||||||
hoverable: Boolean,
|
const visibility = ref<{ [id: string]: boolean }>({});
|
||||||
},
|
const tagsContainer = ref<ComponentInstance<typeof IntersectionObserver>>();
|
||||||
emits: {
|
|
||||||
click: null,
|
// Computed
|
||||||
},
|
const style = computed(() => ({
|
||||||
data() {
|
'max-width': `${maxWidth.value}px`,
|
||||||
return {
|
}));
|
||||||
maxWidth: 320,
|
|
||||||
intersectionEventBus: createEventBus(),
|
const tags = computed(() => {
|
||||||
visibility: {} as { [id: string]: boolean },
|
const allTags = props.tagIds.map((tagId: string) => props.tagsById[tagId]).filter(Boolean);
|
||||||
debouncedSetMaxWidth: () => {},
|
|
||||||
|
let toDisplay: Array<ITag & { hidden?: boolean; title?: string; isCount?: boolean }> = props.limit
|
||||||
|
? allTags.slice(0, props.limit)
|
||||||
|
: allTags;
|
||||||
|
|
||||||
|
toDisplay = toDisplay.map((tag: ITag) => ({
|
||||||
|
...tag,
|
||||||
|
hidden: props.responsive && !visibility.value[tag.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
let visibleCount = toDisplay.length;
|
||||||
|
if (props.responsive) {
|
||||||
|
visibleCount = Object.values(visibility.value).reduce(
|
||||||
|
(accu, val) => (val ? accu + 1 : accu),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleCount < allTags.length) {
|
||||||
|
const hidden = allTags.slice(visibleCount);
|
||||||
|
const hiddenTitle = hidden.reduce(
|
||||||
|
(accu: string, tag: ITag) => (accu ? `${accu}, ${tag.name}` : tag.name),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
const countTag = {
|
||||||
|
id: 'count',
|
||||||
|
name: `+${hidden.length}`,
|
||||||
|
title: hiddenTitle,
|
||||||
|
isCount: true,
|
||||||
};
|
};
|
||||||
},
|
toDisplay.splice(visibleCount, 0, countTag);
|
||||||
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
|
|
||||||
|
|
||||||
let toDisplay: TagEl[] = this.limit ? tags.slice(0, this.limit) : tags;
|
return toDisplay;
|
||||||
toDisplay = toDisplay.map((tag: ITag) => ({
|
});
|
||||||
...tag,
|
|
||||||
hidden: this.responsive && !this.visibility[tag.id],
|
|
||||||
}));
|
|
||||||
|
|
||||||
let visibleCount = toDisplay.length;
|
// Methods
|
||||||
if (this.responsive) {
|
const setMaxWidth = () => {
|
||||||
visibleCount = Object.values(this.visibility).reduce(
|
const container = tagsContainer.value?.$el as HTMLElement;
|
||||||
(accu, val) => (val ? accu + 1 : accu),
|
const parent = container?.parentNode as HTMLElement;
|
||||||
0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleCount < tags.length) {
|
if (parent) {
|
||||||
const hidden = tags.slice(visibleCount);
|
maxWidth.value = 0;
|
||||||
const hiddenTitle = hidden.reduce((accu: string, tag: ITag) => {
|
void nextTick(() => {
|
||||||
return accu ? `${accu}, ${tag.name}` : tag.name;
|
maxWidth.value = parent.clientWidth;
|
||||||
}, '');
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const countTag: TagEl = {
|
const debouncedSetMaxWidth = debounce(setMaxWidth, 100);
|
||||||
id: 'count',
|
|
||||||
name: `+${hidden.length}`,
|
|
||||||
title: hiddenTitle,
|
|
||||||
isCount: true,
|
|
||||||
};
|
|
||||||
toDisplay.splice(visibleCount, 0, countTag);
|
|
||||||
}
|
|
||||||
|
|
||||||
return toDisplay;
|
const onObserved = ({ el, isIntersecting }: { el: HTMLElement; isIntersecting: boolean }) => {
|
||||||
},
|
if (el.dataset.id) {
|
||||||
},
|
visibility.value = { ...visibility.value, [el.dataset.id]: isIntersecting };
|
||||||
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;
|
|
||||||
|
|
||||||
if (parent) {
|
const onClick = (e: MouseEvent, tag: ITag & { hidden?: boolean }) => {
|
||||||
this.maxWidth = 0;
|
if (props.clickable) {
|
||||||
void this.$nextTick(() => {
|
e.stopPropagation();
|
||||||
this.maxWidth = parent.clientWidth;
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onObserved({ el, isIntersecting }: { el: HTMLElement; isIntersecting: boolean }) {
|
|
||||||
if (el.dataset.id) {
|
|
||||||
this.visibility = { ...this.visibility, [el.dataset.id]: isIntersecting };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick(e: MouseEvent, tag: TagEl) {
|
|
||||||
if (this.clickable) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
// if tag is hidden or not displayed
|
if (!tag.hidden) {
|
||||||
if (!tag.hidden) {
|
emit('click', tag.id);
|
||||||
this.$emit('click', tag.id);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
},
|
// Lifecycle hooks
|
||||||
|
onMounted(() => {
|
||||||
|
setMaxWidth();
|
||||||
|
window.addEventListener('resize', debouncedSetMaxWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', debouncedSetMaxWidth);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,236 +1,194 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import type { ITag } from '@/Interface';
|
|
||||||
import { MAX_TAG_NAME_LENGTH, TAGS_MANAGER_MODAL_KEY } from '@/constants';
|
|
||||||
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useTagsStore } from '@/stores/tags.store';
|
|
||||||
import type { EventBus, N8nOption, N8nSelect } from 'n8n-design-system';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
|
import type { ITag } from '@/Interface';
|
||||||
|
import { MAX_TAG_NAME_LENGTH } from '@/constants';
|
||||||
|
import { N8nOption, N8nSelect } from 'n8n-design-system';
|
||||||
|
import type { EventBus } from 'n8n-design-system';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
type SelectRef = InstanceType<typeof N8nSelect>;
|
interface TagsDropdownProps {
|
||||||
type TagRef = InstanceType<typeof N8nOption>;
|
placeholder: string;
|
||||||
type CreateRef = InstanceType<typeof N8nOption>;
|
modelValue: string[];
|
||||||
|
createTag: (name: string) => Promise<ITag>;
|
||||||
|
eventBus: EventBus | null;
|
||||||
|
allTags: ITag[];
|
||||||
|
isLoading: boolean;
|
||||||
|
tagsById: Record<string, ITag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TagsDropdownProps>(), {
|
||||||
|
placeholder: '',
|
||||||
|
modelValue: () => [],
|
||||||
|
eventBus: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [selected: string[]];
|
||||||
|
esc: [];
|
||||||
|
blur: [];
|
||||||
|
'manage-tags': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
const MANAGE_KEY = '__manage';
|
const MANAGE_KEY = '__manage';
|
||||||
const CREATE_KEY = '__create';
|
const CREATE_KEY = '__create';
|
||||||
|
|
||||||
export default defineComponent({
|
const selectRef = ref<InstanceType<typeof N8nSelect>>();
|
||||||
name: 'TagsDropdown',
|
const tagRefs = ref<Array<InstanceType<typeof N8nOption>>>();
|
||||||
props: {
|
const createRef = ref<InstanceType<typeof N8nOption>>();
|
||||||
placeholder: {},
|
|
||||||
modelValue: {
|
const filter = ref('');
|
||||||
type: Array as PropType<string[]>,
|
const focused = ref(false);
|
||||||
default: () => [],
|
const preventUpdate = ref(false);
|
||||||
},
|
|
||||||
eventBus: {
|
const container = ref<HTMLDivElement>();
|
||||||
type: Object as PropType<EventBus>,
|
|
||||||
default: null,
|
const dropdownId = uuid();
|
||||||
},
|
|
||||||
|
const options = computed<ITag[]>(() => {
|
||||||
|
return props.allTags.filter((tag: ITag) => tag && tag.name.includes(filter.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const appliedTags = computed<string[]>(() => {
|
||||||
|
return props.modelValue.filter((id: string) => props.tagsById[id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.allTags,
|
||||||
|
() => {
|
||||||
|
if (props.modelValue.length !== appliedTags.value.length) {
|
||||||
|
emit('update:modelValue', appliedTags.value);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
emits: {
|
);
|
||||||
'update:modelValue': null,
|
|
||||||
esc: null,
|
|
||||||
blur: null,
|
|
||||||
},
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const i18n = useI18n();
|
|
||||||
const { showError } = useToast();
|
|
||||||
const tagsStore = useTagsStore();
|
|
||||||
const uiStore = useUIStore();
|
|
||||||
|
|
||||||
const { isLoading } = storeToRefs(tagsStore);
|
onMounted(() => {
|
||||||
|
const select = selectRef.value?.innerSelect;
|
||||||
|
|
||||||
const selectRef = ref<SelectRef | undefined>();
|
if (select) {
|
||||||
const tagRefs = ref<TagRef[] | undefined>();
|
const input = select.$refs.input as Element | undefined;
|
||||||
const createRef = ref<CreateRef | undefined>();
|
|
||||||
|
|
||||||
const tags = ref([]);
|
if (input) {
|
||||||
const filter = ref('');
|
input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`);
|
||||||
const focused = ref(false);
|
input.addEventListener('keydown', (e: Event) => {
|
||||||
const preventUpdate = ref(false);
|
const keyboardEvent = e as KeyboardEvent;
|
||||||
|
if (keyboardEvent.key === 'Escape') {
|
||||||
const container = ref<HTMLDivElement | undefined>();
|
emit('esc');
|
||||||
|
} else if (keyboardEvent.key === 'Enter' && filter.value.length === 0) {
|
||||||
const allTags = computed<ITag[]>(() => {
|
preventUpdate.value = true;
|
||||||
return tagsStore.allTags;
|
emit('blur');
|
||||||
});
|
if (typeof selectRef.value?.blur === 'function') {
|
||||||
|
selectRef.value.blur();
|
||||||
const options = computed<ITag[]>(() => {
|
|
||||||
return allTags.value.filter((tag: ITag) => tag && tag.name.includes(filter.value));
|
|
||||||
});
|
|
||||||
|
|
||||||
const appliedTags = computed<string[]>(() => {
|
|
||||||
return props.modelValue.filter((id: string) => tagsStore.tagsById[id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => allTags.value,
|
|
||||||
() => {
|
|
||||||
// keep applied tags in sync with store, for example in case tag is deleted from store
|
|
||||||
if (props.modelValue.length !== appliedTags.value.length) {
|
|
||||||
emit('update:modelValue', appliedTags.value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const select = selectRef.value?.$refs?.innerSelect as
|
|
||||||
| { $refs: { input: Element } }
|
|
||||||
| undefined;
|
|
||||||
if (select) {
|
|
||||||
const input = select.$refs.input as Element | undefined;
|
|
||||||
if (input) {
|
|
||||||
input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`);
|
|
||||||
input.addEventListener('keydown', (e: Event) => {
|
|
||||||
const keyboardEvent = e as KeyboardEvent;
|
|
||||||
// events don't bubble outside of select, so need to hook onto input
|
|
||||||
if (keyboardEvent.key === 'Escape') {
|
|
||||||
emit('esc');
|
|
||||||
} else if (keyboardEvent.key === 'Enter' && filter.value.length === 0) {
|
|
||||||
preventUpdate.value = true;
|
|
||||||
|
|
||||||
emit('blur');
|
|
||||||
|
|
||||||
if (typeof selectRef.value?.blur === 'function') {
|
|
||||||
selectRef.value.blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
props.eventBus?.on('focus', onBusFocus);
|
|
||||||
|
|
||||||
void tagsStore.fetchAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
props.eventBus?.off('focus', onBusFocus);
|
|
||||||
});
|
|
||||||
|
|
||||||
function onBusFocus() {
|
|
||||||
focusOnInput();
|
|
||||||
focusFirstOption();
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterOptions(value = '') {
|
|
||||||
filter.value = value;
|
|
||||||
void nextTick(() => focusFirstOption());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onCreate() {
|
|
||||||
const name = filter.value;
|
|
||||||
try {
|
|
||||||
const newTag = await tagsStore.create(name);
|
|
||||||
emit('update:modelValue', [...props.modelValue, newTag.id]);
|
|
||||||
|
|
||||||
filter.value = '';
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
error,
|
|
||||||
i18n.baseText('tagsDropdown.showError.title'),
|
|
||||||
i18n.baseText('tagsDropdown.showError.message', { interpolate: { name } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTagsUpdated(selected: string[]) {
|
|
||||||
const manage = selected.find((value) => value === MANAGE_KEY);
|
|
||||||
const create = selected.find((value) => value === CREATE_KEY);
|
|
||||||
|
|
||||||
if (manage) {
|
|
||||||
filter.value = '';
|
|
||||||
uiStore.openModal(TAGS_MANAGER_MODAL_KEY);
|
|
||||||
emit('blur');
|
|
||||||
} else if (create) {
|
|
||||||
void onCreate();
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!preventUpdate.value) {
|
|
||||||
emit('update:modelValue', selected);
|
|
||||||
}
|
}
|
||||||
preventUpdate.value = false;
|
}
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusFirstOption() {
|
|
||||||
// focus on create option
|
|
||||||
if (createRef.value?.$el) {
|
|
||||||
createRef.value.$el.dispatchEvent(new Event('mouseenter'));
|
|
||||||
}
|
|
||||||
// focus on top option after filter
|
|
||||||
else if (tagRefs.value?.[0]?.$el) {
|
|
||||||
tagRefs.value[0].$el.dispatchEvent(new Event('mouseenter'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusOnInput() {
|
|
||||||
if (selectRef.value) {
|
|
||||||
selectRef.value.focusOnInput();
|
|
||||||
focused.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onVisibleChange(visible: boolean) {
|
|
||||||
if (!visible) {
|
|
||||||
filter.value = '';
|
|
||||||
focused.value = false;
|
|
||||||
} else {
|
|
||||||
focused.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRemoveTag() {
|
|
||||||
void nextTick(() => {
|
|
||||||
focusOnInput();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onClickOutside(
|
props.eventBus?.on('focus', onBusFocus);
|
||||||
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(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
props.eventBus?.off('focus', onBusFocus);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onBusFocus() {
|
||||||
|
focusOnInput();
|
||||||
|
focusFirstOption();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOptions(value = '') {
|
||||||
|
filter.value = value;
|
||||||
|
void nextTick(() => focusFirstOption());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
const name = filter.value;
|
||||||
|
try {
|
||||||
|
const newTag = await props.createTag(name);
|
||||||
|
emit('update:modelValue', [...props.modelValue, newTag.id]);
|
||||||
|
|
||||||
|
filter.value = '';
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
error,
|
||||||
|
i18n.baseText('tagsDropdown.showError.title'),
|
||||||
|
i18n.baseText('tagsDropdown.showError.message', { interpolate: { name } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTagsUpdated(selected: string[]) {
|
||||||
|
const manage = selected.find((value) => value === MANAGE_KEY);
|
||||||
|
const create = selected.find((value) => value === CREATE_KEY);
|
||||||
|
|
||||||
|
if (manage) {
|
||||||
|
filter.value = '';
|
||||||
|
emit('manage-tags');
|
||||||
|
emit('blur');
|
||||||
|
} else if (create) {
|
||||||
|
void onCreate();
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!preventUpdate.value) {
|
||||||
|
emit('update:modelValue', selected);
|
||||||
|
}
|
||||||
|
preventUpdate.value = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirstOption() {
|
||||||
|
// focus on create option
|
||||||
|
if (createRef.value?.$el) {
|
||||||
|
createRef.value.$el.dispatchEvent(new Event('mouseenter'));
|
||||||
|
}
|
||||||
|
// focus on top option after filter
|
||||||
|
else if (tagRefs.value?.[0]?.$el) {
|
||||||
|
tagRefs.value[0].$el.dispatchEvent(new Event('mouseenter'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusOnInput() {
|
||||||
|
if (selectRef.value) {
|
||||||
|
selectRef.value.focusOnInput();
|
||||||
|
focused.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibleChange(visible: boolean) {
|
||||||
|
if (!visible) {
|
||||||
|
filter.value = '';
|
||||||
|
focused.value = false;
|
||||||
|
} else {
|
||||||
|
focused.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoveTag() {
|
||||||
|
void nextTick(() => {
|
||||||
|
focusOnInput();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickOutside(
|
||||||
|
container,
|
||||||
|
() => {
|
||||||
|
emit('blur');
|
||||||
|
},
|
||||||
|
{ ignore: [`.tags-dropdown-${dropdownId}`, '#tags-manager-modal'] },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
|
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
|
||||||
<n8n-select
|
<N8nSelect
|
||||||
ref="selectRef"
|
ref="selectRef"
|
||||||
:teleported="true"
|
:teleported="true"
|
||||||
:model-value="appliedTags"
|
:model-value="appliedTags"
|
||||||
|
@ -241,13 +199,13 @@ export default defineComponent({
|
||||||
multiple
|
multiple
|
||||||
:reserve-keyword="false"
|
:reserve-keyword="false"
|
||||||
loading-text="..."
|
loading-text="..."
|
||||||
popper-class="tags-dropdown"
|
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId]"
|
||||||
data-test-id="tags-dropdown"
|
data-test-id="tags-dropdown"
|
||||||
@update:model-value="onTagsUpdated"
|
@update:model-value="onTagsUpdated"
|
||||||
@visible-change="onVisibleChange"
|
@visible-change="onVisibleChange"
|
||||||
@remove-tag="onRemoveTag"
|
@remove-tag="onRemoveTag"
|
||||||
>
|
>
|
||||||
<n8n-option
|
<N8nOption
|
||||||
v-if="options.length === 0 && filter"
|
v-if="options.length === 0 && filter"
|
||||||
:key="CREATE_KEY"
|
:key="CREATE_KEY"
|
||||||
ref="createRef"
|
ref="createRef"
|
||||||
|
@ -258,17 +216,16 @@ export default defineComponent({
|
||||||
<span>
|
<span>
|
||||||
{{ i18n.baseText('tagsDropdown.createTag', { interpolate: { filter } }) }}
|
{{ i18n.baseText('tagsDropdown.createTag', { interpolate: { filter } }) }}
|
||||||
</span>
|
</span>
|
||||||
</n8n-option>
|
</N8nOption>
|
||||||
<n8n-option v-else-if="options.length === 0" value="message" disabled>
|
<N8nOption v-else-if="options.length === 0" value="message" disabled>
|
||||||
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
||||||
<span v-if="allTags.length > 0">{{
|
<span v-if="allTags.length > 0">{{
|
||||||
i18n.baseText('tagsDropdown.noMatchingTagsExist')
|
i18n.baseText('tagsDropdown.noMatchingTagsExist')
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else-if="filter">{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
|
<span v-else-if="filter">{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
|
||||||
</n8n-option>
|
</N8nOption>
|
||||||
|
|
||||||
<!-- key is id+index for keyboard navigation to work well with filter -->
|
<N8nOption
|
||||||
<n8n-option
|
|
||||||
v-for="(tag, i) in options"
|
v-for="(tag, i) in options"
|
||||||
:key="tag.id + '_' + i"
|
:key="tag.id + '_' + i"
|
||||||
ref="tagRefs"
|
ref="tagRefs"
|
||||||
|
@ -278,11 +235,11 @@ export default defineComponent({
|
||||||
data-test-id="tag"
|
data-test-id="tag"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<n8n-option :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
|
<N8nOption :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
|
||||||
<font-awesome-icon icon="cog" />
|
<font-awesome-icon icon="cog" />
|
||||||
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
|
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
|
||||||
</n8n-option>
|
</N8nOption>
|
||||||
</n8n-select>
|
</N8nSelect>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<div :class="$style.container">
|
<div :class="$style.container">
|
||||||
<el-col class="notags" :span="16">
|
<el-col class="notags" :span="16">
|
||||||
|
@ -5,11 +19,11 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-s">
|
<div class="mb-s">
|
||||||
<n8n-heading size="large">
|
<n8n-heading size="large">
|
||||||
{{ $locale.baseText('noTagsView.readyToOrganizeYourWorkflows') }}
|
{{ $locale.baseText(titleLocaleKey) }}
|
||||||
</n8n-heading>
|
</n8n-heading>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
{{ $locale.baseText('noTagsView.withWorkflowTagsYouReFree') }}
|
{{ $locale.baseText(descriptionLocaleKey) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n8n-button label="Create a tag" size="large" @click="$emit('enableCreate')" />
|
<n8n-button label="Create a tag" size="large" @click="$emit('enableCreate')" />
|
||||||
|
|
|
@ -1,167 +1,154 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
|
||||||
import type { ITag } from '@/Interface';
|
import type { ITag } from '@/Interface';
|
||||||
|
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import TagsView from '@/components/TagsManager/TagsView/TagsView.vue';
|
import TagsView from '@/components/TagsManager/TagsView/TagsView.vue';
|
||||||
import NoTagsView from '@/components/TagsManager/NoTagsView.vue';
|
import NoTagsView from '@/components/TagsManager/NoTagsView.vue';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import { TAGS_MANAGER_MODAL_KEY } from '@/constants';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useTagsStore } from '@/stores/tags.store';
|
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
|
||||||
export default defineComponent({
|
interface TagsManagerProps {
|
||||||
name: 'TagsManager',
|
modalKey: string;
|
||||||
components: {
|
usageLocaleKey: BaseTextKey;
|
||||||
TagsView,
|
usageColumnTitleLocaleKey: BaseTextKey;
|
||||||
NoTagsView,
|
titleLocaleKey: BaseTextKey;
|
||||||
Modal,
|
noTagsTitleLocaleKey: BaseTextKey;
|
||||||
},
|
noTagsDescriptionLocaleKey: BaseTextKey;
|
||||||
setup() {
|
tags: ITag[];
|
||||||
return {
|
isLoading: boolean;
|
||||||
...useToast(),
|
onFetchTags: () => Promise<void>;
|
||||||
};
|
onCreateTag: (name: string) => Promise<ITag>;
|
||||||
},
|
onUpdateTag: (id: string, name: string) => Promise<ITag>;
|
||||||
data() {
|
onDeleteTag: (id: string) => Promise<boolean>;
|
||||||
const tagIds = useTagsStore().allTags.map((tag) => tag.id);
|
}
|
||||||
return {
|
|
||||||
tagIds,
|
|
||||||
isCreating: false,
|
|
||||||
modalBus: createEventBus(),
|
|
||||||
TAGS_MANAGER_MODAL_KEY,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
void this.tagsStore.fetchAll({ force: true, withUsageCount: true });
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useTagsStore),
|
|
||||||
isLoading(): boolean {
|
|
||||||
return this.tagsStore.isLoading;
|
|
||||||
},
|
|
||||||
tags(): ITag[] {
|
|
||||||
return this.tagIds.map((tagId: string) => this.tagsStore.tagsById[tagId]).filter(Boolean); // if tag is deleted from store
|
|
||||||
},
|
|
||||||
hasTags(): boolean {
|
|
||||||
return this.tags.length > 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onEnableCreate() {
|
|
||||||
this.isCreating = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
onDisableCreate() {
|
const props = withDefaults(defineProps<TagsManagerProps>(), {
|
||||||
this.isCreating = false;
|
titleLocaleKey: 'tagsManager.manageTags',
|
||||||
},
|
usageLocaleKey: 'tagsView.inUse',
|
||||||
|
usageColumnTitleLocaleKey: 'tagsTable.usage',
|
||||||
async onCreate(name: string, cb: (tag: ITag | null, error?: Error) => void) {
|
noTagsTitleLocaleKey: 'noTagsView.readyToOrganizeYourWorkflows',
|
||||||
try {
|
noTagsDescriptionLocaleKey: 'noTagsView.withWorkflowTagsYouReFree',
|
||||||
if (!name) {
|
|
||||||
throw new Error(this.$locale.baseText('tagsManager.tagNameCannotBeEmpty'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTag = await this.tagsStore.create(name);
|
|
||||||
this.tagIds = [newTag.id].concat(this.tagIds);
|
|
||||||
cb(newTag);
|
|
||||||
} catch (error) {
|
|
||||||
const escapedName = escape(name);
|
|
||||||
this.showError(
|
|
||||||
error,
|
|
||||||
this.$locale.baseText('tagsManager.showError.onCreate.title'),
|
|
||||||
this.$locale.baseText('tagsManager.showError.onCreate.message', {
|
|
||||||
interpolate: { escapedName },
|
|
||||||
}) + ':',
|
|
||||||
);
|
|
||||||
cb(null, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async onUpdate(id: string, name: string, cb: (tag: boolean, error?: Error) => void) {
|
|
||||||
const tag = this.tagsStore.tagsById[id];
|
|
||||||
const oldName = tag.name;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!name) {
|
|
||||||
throw new Error(this.$locale.baseText('tagsManager.tagNameCannotBeEmpty'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === oldName) {
|
|
||||||
cb(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedTag = await this.tagsStore.rename({ id, name });
|
|
||||||
cb(!!updatedTag);
|
|
||||||
|
|
||||||
this.showMessage({
|
|
||||||
title: this.$locale.baseText('tagsManager.showMessage.onUpdate.title'),
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const escapedName = escape(oldName);
|
|
||||||
this.showError(
|
|
||||||
error,
|
|
||||||
this.$locale.baseText('tagsManager.showError.onUpdate.title'),
|
|
||||||
this.$locale.baseText('tagsManager.showError.onUpdate.message', {
|
|
||||||
interpolate: { escapedName },
|
|
||||||
}) + ':',
|
|
||||||
);
|
|
||||||
cb(false, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async onDelete(id: string, cb: (deleted: boolean, error?: Error) => void) {
|
|
||||||
const tag = this.tagsStore.tagsById[id];
|
|
||||||
const name = tag.name;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deleted = await this.tagsStore.deleteTagById(id);
|
|
||||||
if (!deleted) {
|
|
||||||
throw new Error(this.$locale.baseText('tagsManager.couldNotDeleteTag'));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tagIds = this.tagIds.filter((tagId: string) => tagId !== id);
|
|
||||||
|
|
||||||
cb(deleted);
|
|
||||||
|
|
||||||
this.showMessage({
|
|
||||||
title: this.$locale.baseText('tagsManager.showMessage.onDelete.title'),
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const escapedName = escape(name);
|
|
||||||
this.showError(
|
|
||||||
error,
|
|
||||||
this.$locale.baseText('tagsManager.showError.onDelete.title'),
|
|
||||||
this.$locale.baseText('tagsManager.showError.onDelete.message', {
|
|
||||||
interpolate: { escapedName },
|
|
||||||
}) + ':',
|
|
||||||
);
|
|
||||||
cb(false, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onEnter() {
|
|
||||||
if (this.isLoading) {
|
|
||||||
return;
|
|
||||||
} else if (!this.hasTags) {
|
|
||||||
this.onEnableCreate();
|
|
||||||
} else {
|
|
||||||
this.modalBus.emit('close');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:tags': [tags: ITag[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const tagIds = ref(props.tags.map((tag) => tag.id));
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const modalBus = createEventBus();
|
||||||
|
|
||||||
|
const tags = computed(() =>
|
||||||
|
tagIds.value
|
||||||
|
.map((tagId) => props.tags.find((tag) => tag.id === tagId))
|
||||||
|
.filter((tag): tag is ITag => Boolean(tag)),
|
||||||
|
);
|
||||||
|
const hasTags = computed(() => tags.value.length > 0);
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void props.onFetchTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onEnableCreate() {
|
||||||
|
isCreating.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisableCreate() {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreate(name: string, createCallback: (tag: ITag | null, error?: Error) => void) {
|
||||||
|
try {
|
||||||
|
if (!name) {
|
||||||
|
throw new Error(i18n.baseText('tagsManager.tagNameCannotBeEmpty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTag = await props.onCreateTag(name);
|
||||||
|
tagIds.value = [newTag.id, ...tagIds.value];
|
||||||
|
emit('update:tags', [...props.tags, newTag]);
|
||||||
|
createCallback(newTag);
|
||||||
|
} catch (error) {
|
||||||
|
// const escapedName = escape(name);
|
||||||
|
// Implement showError function or emit an event for error handling
|
||||||
|
createCallback(null, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpdate(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
updateCallback: (success: boolean, error?: Error) => void,
|
||||||
|
) {
|
||||||
|
const tag = props.tags.find((t) => t.id === id);
|
||||||
|
if (!tag) {
|
||||||
|
updateCallback(false, new Error('Tag not found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldName = tag.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!name) {
|
||||||
|
throw new Error(i18n.baseText('tagsManager.tagNameCannotBeEmpty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === oldName) {
|
||||||
|
updateCallback(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTag = await props.onUpdateTag(id, name);
|
||||||
|
emit(
|
||||||
|
'update:tags',
|
||||||
|
props.tags.map((t) => (t.id === id ? updatedTag : t)),
|
||||||
|
);
|
||||||
|
updateCallback(true);
|
||||||
|
} catch (error) {
|
||||||
|
updateCallback(false, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string, deleteCallback: (deleted: boolean, error?: Error) => void) {
|
||||||
|
const tag = props.tags.find((t) => t.id === id);
|
||||||
|
if (!tag) {
|
||||||
|
deleteCallback(false, new Error('Tag not found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deleted = await props.onDeleteTag(id);
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error(i18n.baseText('tagsManager.couldNotDeleteTag'));
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIds.value = tagIds.value.filter((tagId) => tagId !== id);
|
||||||
|
emit(
|
||||||
|
'update:tags',
|
||||||
|
props.tags.filter((t) => t.id !== id),
|
||||||
|
);
|
||||||
|
deleteCallback(deleted);
|
||||||
|
} catch (error) {
|
||||||
|
deleteCallback(false, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter() {
|
||||||
|
if (props.isLoading) {
|
||||||
|
return;
|
||||||
|
} else if (!hasTags.value) {
|
||||||
|
onEnableCreate();
|
||||||
|
} else {
|
||||||
|
modalBus.emit('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
id="tags-manager-modal"
|
:title="i18n.baseText(titleLocaleKey)"
|
||||||
:title="$locale.baseText('tagsManager.manageTags')"
|
:name="modalKey"
|
||||||
:name="TAGS_MANAGER_MODAL_KEY"
|
|
||||||
:event-bus="modalBus"
|
:event-bus="modalBus"
|
||||||
min-width="620px"
|
min-width="620px"
|
||||||
min-height="420px"
|
min-height="420px"
|
||||||
|
@ -173,16 +160,23 @@ export default defineComponent({
|
||||||
v-if="hasTags || isCreating"
|
v-if="hasTags || isCreating"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:tags="tags"
|
:tags="tags"
|
||||||
|
:usage-locale-key="usageLocaleKey"
|
||||||
|
:usage-column-title-locale-key="usageColumnTitleLocaleKey"
|
||||||
@create="onCreate"
|
@create="onCreate"
|
||||||
@update="onUpdate"
|
@update="onUpdate"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
@disable-create="onDisableCreate"
|
@disable-create="onDisableCreate"
|
||||||
/>
|
/>
|
||||||
<NoTagsView v-else @enable-create="onEnableCreate" />
|
<NoTagsView
|
||||||
|
v-else
|
||||||
|
:title-locale-key="noTagsTitleLocaleKey"
|
||||||
|
:description-locale-key="noTagsDescriptionLocaleKey"
|
||||||
|
@enable-create="onEnableCreate"
|
||||||
|
/>
|
||||||
</el-row>
|
</el-row>
|
||||||
</template>
|
</template>
|
||||||
<template #footer="{ close }">
|
<template #footer="{ close }">
|
||||||
<n8n-button :label="$locale.baseText('tagsManager.done')" float="right" @click="close" />
|
<n8n-button :label="i18n.baseText('tagsManager.done')" float="right" @click="close" />
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2,8 +2,10 @@
|
||||||
import type { ElTable } from 'element-plus';
|
import type { ElTable } from 'element-plus';
|
||||||
import { MAX_TAG_NAME_LENGTH } from '@/constants';
|
import { MAX_TAG_NAME_LENGTH } from '@/constants';
|
||||||
import type { ITagRow } from '@/Interface';
|
import type { ITagRow } from '@/Interface';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import type { N8nInput } from 'n8n-design-system';
|
import type { N8nInput } from 'n8n-design-system';
|
||||||
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
|
||||||
type TableRef = InstanceType<typeof ElTable>;
|
type TableRef = InstanceType<typeof ElTable>;
|
||||||
type N8nInputRef = InstanceType<typeof N8nInput>;
|
type N8nInputRef = InstanceType<typeof N8nInput>;
|
||||||
|
@ -13,7 +15,28 @@ const DELETE_TRANSITION_TIMEOUT = 100;
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'TagsTable',
|
name: 'TagsTable',
|
||||||
props: ['rows', 'isLoading', 'newName', 'isSaving'],
|
props: {
|
||||||
|
rows: {
|
||||||
|
type: Array as () => ITagRow[],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
newName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
usageColumnTitleLocaleKey: {
|
||||||
|
type: String as PropType<BaseTextKey>,
|
||||||
|
default: 'tagsTable.usage',
|
||||||
|
},
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
maxLength: MAX_TAG_NAME_LENGTH,
|
maxLength: MAX_TAG_NAME_LENGTH,
|
||||||
|
@ -139,7 +162,7 @@ export default defineComponent({
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$locale.baseText('tagsTable.usage')" width="150">
|
<el-table-column :label="$locale.baseText(usageColumnTitleLocaleKey)" width="170">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { PropType } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
import type { ITag, ITagRow } from '@/Interface';
|
import type { ITag, ITagRow } from '@/Interface';
|
||||||
|
@ -7,6 +8,7 @@ import TagsTable from '@/components/TagsManager/TagsView/TagsTable.vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useRBACStore } from '@/stores/rbac.store';
|
import { useRBACStore } from '@/stores/rbac.store';
|
||||||
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
|
||||||
const matches = (name: string, filter: string) =>
|
const matches = (name: string, filter: string) =>
|
||||||
name.toLowerCase().trim().includes(filter.toLowerCase().trim());
|
name.toLowerCase().trim().includes(filter.toLowerCase().trim());
|
||||||
|
@ -15,6 +17,14 @@ export default defineComponent({
|
||||||
name: 'TagsView',
|
name: 'TagsView',
|
||||||
components: { TagsTableHeader, TagsTable },
|
components: { TagsTableHeader, TagsTable },
|
||||||
props: {
|
props: {
|
||||||
|
usageColumnTitleLocaleKey: {
|
||||||
|
type: String as PropType<BaseTextKey>,
|
||||||
|
default: 'tagsTable.usage',
|
||||||
|
},
|
||||||
|
usageLocaleKey: {
|
||||||
|
type: String as PropType<BaseTextKey>,
|
||||||
|
default: 'tagsView.inUse',
|
||||||
|
},
|
||||||
tags: {
|
tags: {
|
||||||
type: Array as () => ITag[],
|
type: Array as () => ITag[],
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -49,7 +59,7 @@ export default defineComponent({
|
||||||
rows(): ITagRow[] {
|
rows(): ITagRow[] {
|
||||||
const getUsage = (count: number | undefined) =>
|
const getUsage = (count: number | undefined) =>
|
||||||
count && count > 0
|
count && count > 0
|
||||||
? this.$locale.baseText('tagsView.inUse', { adjustToNumber: count })
|
? this.$locale.baseText(this.usageLocaleKey, { adjustToNumber: count })
|
||||||
: this.$locale.baseText('tagsView.notBeingUsed');
|
: this.$locale.baseText('tagsView.notBeingUsed');
|
||||||
|
|
||||||
const disabled = this.isCreateEnabled || !!this.updateId || !!this.deleteId;
|
const disabled = this.isCreateEnabled || !!this.updateId || !!this.deleteId;
|
||||||
|
@ -182,6 +192,7 @@ export default defineComponent({
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:is-saving="isSaving"
|
:is-saving="isSaving"
|
||||||
:new-name="newName"
|
:new-name="newName"
|
||||||
|
:usage-column-title-locale-key="usageColumnTitleLocaleKey"
|
||||||
data-test-id="tags-table"
|
data-test-id="tags-table"
|
||||||
@new-name-change="onNewNameChange"
|
@new-name-change="onNewNameChange"
|
||||||
@update-enable="onUpdateEnable"
|
@update-enable="onUpdateEnable"
|
||||||
|
|
|
@ -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',
|
status: 'all',
|
||||||
workflowId: 'all',
|
workflowId: 'all',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
annotationTags: [],
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
metadata: [{ key: '', value: '' }],
|
metadata: [{ key: '', value: '' }],
|
||||||
|
vote: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
const workflowDataFactory = (): IWorkflowShortResponse => ({
|
const workflowDataFactory = (): IWorkflowShortResponse => ({
|
||||||
|
|
|
@ -7,14 +7,15 @@ import type {
|
||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import { i18n as locale } from '@/plugins/i18n';
|
import { i18n as locale } from '@/plugins/i18n';
|
||||||
import TagsDropdown from '@/components/TagsDropdown.vue';
|
|
||||||
import { getObjectKeys, isEmpty } from '@/utils/typesUtils';
|
import { getObjectKeys, isEmpty } from '@/utils/typesUtils';
|
||||||
import { EnterpriseEditionFeature } from '@/constants';
|
import { EnterpriseEditionFeature, EXECUTION_ANNOTATION_EXPERIMENT } from '@/constants';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import type { Placement } from '@floating-ui/core';
|
import type { Placement } from '@floating-ui/core';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
|
||||||
|
|
||||||
export type ExecutionFilterProps = {
|
export type ExecutionFilterProps = {
|
||||||
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;
|
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;
|
||||||
|
@ -26,6 +27,7 @@ const DATE_TIME_MASK = 'YYYY-MM-DD HH:mm';
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
const posthogStore = usePostHog();
|
||||||
const { debounce } = useDebounce();
|
const { debounce } = useDebounce();
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
@ -46,15 +48,22 @@ const isCustomDataFilterTracked = ref(false);
|
||||||
const isAdvancedExecutionFilterEnabled = computed(
|
const isAdvancedExecutionFilterEnabled = computed(
|
||||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
|
||||||
);
|
);
|
||||||
|
const isAnnotationFiltersEnabled = computed(
|
||||||
|
() =>
|
||||||
|
isAdvancedExecutionFilterEnabled.value &&
|
||||||
|
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
|
||||||
|
);
|
||||||
const showTags = computed(() => false);
|
const showTags = computed(() => false);
|
||||||
|
|
||||||
const getDefaultFilter = (): ExecutionFilterType => ({
|
const getDefaultFilter = (): ExecutionFilterType => ({
|
||||||
status: 'all',
|
status: 'all',
|
||||||
workflowId: 'all',
|
workflowId: 'all',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
annotationTags: [],
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
metadata: [{ key: '', value: '' }],
|
metadata: [{ key: '', value: '' }],
|
||||||
|
vote: 'all',
|
||||||
});
|
});
|
||||||
const filter = reactive(getDefaultFilter());
|
const filter = reactive(getDefaultFilter());
|
||||||
|
|
||||||
|
@ -90,27 +99,25 @@ const statuses = computed(() => [
|
||||||
{ id: 'waiting', name: locale.baseText('executionsList.waiting') },
|
{ id: 'waiting', name: locale.baseText('executionsList.waiting') },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const voteFilterOptions = computed(() => [
|
||||||
|
{ id: 'all', name: locale.baseText('executionsFilter.annotation.rating.all') },
|
||||||
|
{ id: 'up', name: locale.baseText('executionsFilter.annotation.rating.good') },
|
||||||
|
{ id: 'down', name: locale.baseText('executionsFilter.annotation.rating.bad') },
|
||||||
|
]);
|
||||||
|
|
||||||
const countSelectedFilterProps = computed(() => {
|
const countSelectedFilterProps = computed(() => {
|
||||||
let count = 0;
|
const nonDefaultFilters = [
|
||||||
if (filter.status !== 'all') {
|
filter.status !== 'all',
|
||||||
count++;
|
filter.workflowId !== 'all' && props.workflows.length,
|
||||||
}
|
!isEmpty(filter.tags),
|
||||||
if (filter.workflowId !== 'all' && props.workflows.length) {
|
!isEmpty(filter.annotationTags),
|
||||||
count++;
|
filter.vote !== 'all',
|
||||||
}
|
!isEmpty(filter.metadata),
|
||||||
if (!isEmpty(filter.tags)) {
|
!!filter.startDate,
|
||||||
count++;
|
!!filter.endDate,
|
||||||
}
|
].filter(Boolean);
|
||||||
if (!isEmpty(filter.metadata)) {
|
|
||||||
count++;
|
return nonDefaultFilters.length;
|
||||||
}
|
|
||||||
if (!!filter.startDate) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
if (!!filter.endDate) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// vModel.metadata is a text input and needs a debounced emit to avoid too many requests
|
// vModel.metadata is a text input and needs a debounced emit to avoid too many requests
|
||||||
|
@ -134,8 +141,11 @@ const onFilterMetaChange = (index: number, prop: keyof ExecutionFilterMetadata,
|
||||||
|
|
||||||
// Can't use v-model on TagsDropdown component and thus vModel.tags is useless
|
// Can't use v-model on TagsDropdown component and thus vModel.tags is useless
|
||||||
// We just emit the updated filter
|
// We just emit the updated filter
|
||||||
const onTagsChange = (tags: string[]) => {
|
const onTagsChange = () => {
|
||||||
filter.tags = tags;
|
emit('filterChanged', filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAnnotationTagsChange = () => {
|
||||||
emit('filterChanged', filter);
|
emit('filterChanged', filter);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -194,10 +204,10 @@ onBeforeMount(() => {
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showTags" :class="$style.group">
|
<div v-if="showTags" :class="$style.group">
|
||||||
<label for="execution-filter-tags">{{ locale.baseText('workflows.filters.tags') }}</label>
|
<label for="execution-filter-tags">{{ locale.baseText('workflows.filters.tags') }}</label>
|
||||||
<TagsDropdown
|
<WorkflowTagsDropdown
|
||||||
id="execution-filter-tags"
|
id="execution-filter-tags"
|
||||||
|
v-model="filter.tags"
|
||||||
:placeholder="locale.baseText('workflowOpen.filterWorkflows')"
|
:placeholder="locale.baseText('workflowOpen.filterWorkflows')"
|
||||||
:model-value="filter.tags"
|
|
||||||
:create-enabled="false"
|
:create-enabled="false"
|
||||||
data-test-id="executions-filter-tags-select"
|
data-test-id="executions-filter-tags-select"
|
||||||
@update:model-value="onTagsChange"
|
@update:model-value="onTagsChange"
|
||||||
|
@ -247,19 +257,43 @@ onBeforeMount(() => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isAnnotationFiltersEnabled" :class="$style.group">
|
||||||
|
<label for="execution-filter-annotation-tags">{{
|
||||||
|
locale.baseText('executionsFilter.annotation.tags')
|
||||||
|
}}</label>
|
||||||
|
<AnnotationTagsDropdown
|
||||||
|
id="execution-filter-annotation-tags"
|
||||||
|
:placeholder="locale.baseText('workflowOpen.filterWorkflows')"
|
||||||
|
v-model="filter.annotationTags"
|
||||||
|
:create-enabled="false"
|
||||||
|
data-test-id="executions-filter-annotation-tags-select"
|
||||||
|
@update:model-value="onAnnotationTagsChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAnnotationFiltersEnabled" :class="$style.group">
|
||||||
|
<label for="execution-filter-annotation-vote">{{
|
||||||
|
locale.baseText('executionsFilter.annotation.rating')
|
||||||
|
}}</label>
|
||||||
|
<n8n-select
|
||||||
|
id="execution-filter-annotation-vote"
|
||||||
|
v-model="vModel.vote"
|
||||||
|
:placeholder="locale.baseText('executionsFilter.annotation.selectVoteFilter')"
|
||||||
|
filterable
|
||||||
|
data-test-id="executions-filter-annotation-vote-select"
|
||||||
|
:teleported="teleported"
|
||||||
|
>
|
||||||
|
<n8n-option
|
||||||
|
v-for="(item, idx) in voteFilterOptions"
|
||||||
|
:key="idx"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</n8n-select>
|
||||||
|
</div>
|
||||||
<div :class="$style.group">
|
<div :class="$style.group">
|
||||||
<n8n-tooltip placement="right">
|
<n8n-tooltip placement="right">
|
||||||
<template #content>
|
<template #content>
|
||||||
<i18n-t tag="span" keypath="executionsFilter.customData.docsTooltip">
|
<i18n-t tag="span" keypath="executionsFilter.customData.docsTooltip" />
|
||||||
<template #link>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
href="https://docs.n8n.io/workflows/executions/custom-executions-data/"
|
|
||||||
>
|
|
||||||
{{ locale.baseText('executionsFilter.customData.docsTooltip.link') }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
</template>
|
||||||
<span :class="$style.label">
|
<span :class="$style.label">
|
||||||
{{ locale.baseText('executionsFilter.savedData') }}
|
{{ locale.baseText('executionsFilter.savedData') }}
|
||||||
|
|
|
@ -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 { createComponentRenderer } from '@/__tests__/render';
|
||||||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => ({
|
useRoute: () => ({
|
||||||
|
@ -9,6 +11,24 @@ vi.mock('vue-router', () => ({
|
||||||
RouterLink: vi.fn(),
|
RouterLink: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
[STORES.SETTINGS]: {
|
||||||
|
settings: {
|
||||||
|
templates: {
|
||||||
|
enabled: true,
|
||||||
|
host: 'https://api.n8n.io/api/',
|
||||||
|
},
|
||||||
|
license: {
|
||||||
|
environment: 'development',
|
||||||
|
},
|
||||||
|
deployment: {
|
||||||
|
type: 'default',
|
||||||
|
},
|
||||||
|
enterprise: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
@ -26,7 +46,7 @@ const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
||||||
|
|
||||||
describe('WorkflowExecutionsCard', () => {
|
describe('WorkflowExecutionsCard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createTestingPinia({ initialState }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
|
|
|
@ -2,13 +2,15 @@
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||||
import { VIEWS } from '@/constants';
|
import { EnterpriseEditionFeature, EXECUTION_ANNOTATION_EXPERIMENT, VIEWS } from '@/constants';
|
||||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import type { PermissionsRecord } from '@/permissions';
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
execution: ExecutionSummary;
|
execution: ExecutionSummary;
|
||||||
|
@ -27,6 +29,17 @@ const locale = useI18n();
|
||||||
|
|
||||||
const executionHelpers = useExecutionHelpers();
|
const executionHelpers = useExecutionHelpers();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const posthogStore = usePostHog();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const isAdvancedExecutionFilterEnabled = computed(
|
||||||
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
|
||||||
|
);
|
||||||
|
const isAnnotationEnabled = computed(
|
||||||
|
() =>
|
||||||
|
isAdvancedExecutionFilterEnabled.value &&
|
||||||
|
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
|
||||||
|
);
|
||||||
|
|
||||||
const currentWorkflow = computed(() => (route.params.name as string) || workflowsStore.workflowId);
|
const currentWorkflow = computed(() => (route.params.name as string) || workflowsStore.workflowId);
|
||||||
const retryExecutionActions = computed(() => [
|
const retryExecutionActions = computed(() => [
|
||||||
|
@ -110,6 +123,21 @@ function onRetryMenuItemSelect(action: string): void {
|
||||||
{{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
|
{{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isAnnotationEnabled" :class="$style.annotation">
|
||||||
|
<div v-if="execution.annotation?.vote" :class="$style.ratingIcon">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
v-if="execution.annotation.vote == 'up'"
|
||||||
|
:class="$style.up"
|
||||||
|
icon="thumbs-up"
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon v-else :class="$style.down" icon="thumbs-down" />
|
||||||
|
</div>
|
||||||
|
<N8nTags
|
||||||
|
v-if="executionUIDetails.tags.length > 0"
|
||||||
|
:tags="executionUIDetails.tags"
|
||||||
|
:clickable="false"
|
||||||
|
></N8nTags>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.icons">
|
<div :class="$style.icons">
|
||||||
<N8nActionDropdown
|
<N8nActionDropdown
|
||||||
|
@ -221,6 +249,23 @@ function onRetryMenuItemSelect(action: string): void {
|
||||||
border-left: var(--spacing-4xs) var(--border-style-base) var(--execution-card-border-unknown);
|
border-left: var(--spacing-4xs) var(--border-style-base) var(--execution-card-border-unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.annotation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--spacing-3xs);
|
||||||
|
align-items: center;
|
||||||
|
margin: var(--spacing-4xs) 0 0;
|
||||||
|
|
||||||
|
.ratingIcon {
|
||||||
|
.up {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.down {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.executionLink {
|
.executionLink {
|
||||||
|
@ -269,6 +314,7 @@ function onRetryMenuItemSelect(action: string): void {
|
||||||
margin-left: var(--spacing-2xs);
|
margin-left: var(--spacing-2xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.showGap {
|
.showGap {
|
||||||
margin-bottom: var(--spacing-2xs);
|
margin-bottom: var(--spacing-2xs);
|
||||||
.executionLink {
|
.executionLink {
|
||||||
|
|
|
@ -2,11 +2,18 @@
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import { onBeforeRouteLeave, useRouter } from 'vue-router';
|
import { onBeforeRouteLeave, useRouter } from 'vue-router';
|
||||||
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
||||||
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
|
import {
|
||||||
|
EnterpriseEditionFeature,
|
||||||
|
EXECUTION_ANNOTATION_EXPERIMENT,
|
||||||
|
MAIN_HEADER_TABS,
|
||||||
|
VIEWS,
|
||||||
|
} from '@/constants';
|
||||||
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -36,6 +43,18 @@ const emit = defineEmits<{
|
||||||
const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
|
const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const posthogStore = usePostHog();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const isAdvancedExecutionFilterEnabled = computed(
|
||||||
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
|
||||||
|
);
|
||||||
|
const isAnnotationEnabled = computed(
|
||||||
|
() =>
|
||||||
|
isAdvancedExecutionFilterEnabled.value &&
|
||||||
|
posthogStore.isFeatureEnabled(EXECUTION_ANNOTATION_EXPERIMENT),
|
||||||
|
);
|
||||||
|
|
||||||
const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
|
const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
|
||||||
props.executions.find((execution) => execution.id === props.execution?.id)
|
props.executions.find((execution) => execution.id === props.execution?.id)
|
||||||
? undefined
|
? undefined
|
||||||
|
@ -116,6 +135,10 @@ onBeforeRouteLeave(async (to, _, next) => {
|
||||||
@stop-execution="onStopExecution"
|
@stop-execution="onStopExecution"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<WorkflowExecutionAnnotationSidebar
|
||||||
|
v-if="isAnnotationEnabled && execution"
|
||||||
|
:execution="execution"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ export interface IExecutionUIData {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
runningTime: string;
|
runningTime: string;
|
||||||
showTimestamp: boolean;
|
showTimestamp: boolean;
|
||||||
|
tags: Array<{ id: string; name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExecutionHelpers() {
|
export function useExecutionHelpers() {
|
||||||
|
@ -20,6 +21,7 @@ export function useExecutionHelpers() {
|
||||||
label: 'Status unknown',
|
label: 'Status unknown',
|
||||||
runningTime: '',
|
runningTime: '',
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
|
tags: execution.annotation?.tags ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (execution.status === 'new') {
|
if (execution.status === 'new') {
|
||||||
|
|
|
@ -48,6 +48,7 @@ export const DELETE_USER_MODAL_KEY = 'deleteUser';
|
||||||
export const INVITE_USER_MODAL_KEY = 'inviteUser';
|
export const INVITE_USER_MODAL_KEY = 'inviteUser';
|
||||||
export const DUPLICATE_MODAL_KEY = 'duplicate';
|
export const DUPLICATE_MODAL_KEY = 'duplicate';
|
||||||
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
|
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
|
||||||
|
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
|
||||||
export const VERSIONS_MODAL_KEY = 'versions';
|
export const VERSIONS_MODAL_KEY = 'versions';
|
||||||
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
||||||
export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
|
export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
|
||||||
|
@ -630,6 +631,7 @@ export const enum STORES {
|
||||||
NODE_TYPES = 'nodeTypes',
|
NODE_TYPES = 'nodeTypes',
|
||||||
CREDENTIALS = 'credentials',
|
CREDENTIALS = 'credentials',
|
||||||
TAGS = 'tags',
|
TAGS = 'tags',
|
||||||
|
ANNOTATION_TAGS = 'annotationTags',
|
||||||
VERSIONS = 'versions',
|
VERSIONS = 'versions',
|
||||||
NODE_CREATOR = 'nodeCreator',
|
NODE_CREATOR = 'nodeCreator',
|
||||||
WEBHOOKS = 'webhooks',
|
WEBHOOKS = 'webhooks',
|
||||||
|
@ -691,6 +693,8 @@ export const MORE_ONBOARDING_OPTIONS_EXPERIMENT = {
|
||||||
variant: 'variant',
|
variant: 'variant',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const EXECUTION_ANNOTATION_EXPERIMENT = '023_execution_annotation';
|
||||||
|
|
||||||
export const EXPERIMENTS_TO_TRACK = [
|
export const EXPERIMENTS_TO_TRACK = [
|
||||||
ASK_AI_EXPERIMENT.name,
|
ASK_AI_EXPERIMENT.name,
|
||||||
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
|
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import type { Scope } from '@n8n/permissions';
|
||||||
describe('permissions', () => {
|
describe('permissions', () => {
|
||||||
it('getResourcePermissions for empty scopes', () => {
|
it('getResourcePermissions for empty scopes', () => {
|
||||||
expect(getResourcePermissions()).toEqual({
|
expect(getResourcePermissions()).toEqual({
|
||||||
|
annotationTag: {},
|
||||||
auditLogs: {},
|
auditLogs: {},
|
||||||
banner: {},
|
banner: {},
|
||||||
communityPackage: {},
|
communityPackage: {},
|
||||||
|
@ -58,6 +59,7 @@ describe('permissions', () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
const permissionRecord: PermissionsRecord = {
|
const permissionRecord: PermissionsRecord = {
|
||||||
|
annotationTag: {},
|
||||||
auditLogs: {},
|
auditLogs: {},
|
||||||
banner: {},
|
banner: {},
|
||||||
communityPackage: {},
|
communityPackage: {},
|
||||||
|
|
|
@ -28,6 +28,8 @@
|
||||||
"clientSecret": "Client Secret"
|
"clientSecret": "Client Secret"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"generic.annotations": "Annotations",
|
||||||
|
"generic.annotationData": "Highlighted data",
|
||||||
"generic.any": "Any",
|
"generic.any": "Any",
|
||||||
"generic.cancel": "Cancel",
|
"generic.cancel": "Cancel",
|
||||||
"generic.close": "Close",
|
"generic.close": "Close",
|
||||||
|
@ -51,6 +53,7 @@
|
||||||
"generic.beta": "beta",
|
"generic.beta": "beta",
|
||||||
"generic.yes": "Yes",
|
"generic.yes": "Yes",
|
||||||
"generic.no": "No",
|
"generic.no": "No",
|
||||||
|
"generic.rating": "Rating",
|
||||||
"generic.retry": "Retry",
|
"generic.retry": "Retry",
|
||||||
"generic.error": "Something went wrong",
|
"generic.error": "Something went wrong",
|
||||||
"generic.settings": "Settings",
|
"generic.settings": "Settings",
|
||||||
|
@ -96,6 +99,9 @@
|
||||||
"activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.",
|
"activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.",
|
||||||
"activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
|
"activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
|
||||||
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
|
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
|
||||||
|
"annotationTagsManager.manageTags": "Manage execution tags",
|
||||||
|
"annotationTagsView.usage": "Usage (all workflows)",
|
||||||
|
"annotationTagsView.inUse": "{count} execution | {count} executions",
|
||||||
"auth.changePassword": "Change password",
|
"auth.changePassword": "Change password",
|
||||||
"auth.changePassword.currentPassword": "Current password",
|
"auth.changePassword.currentPassword": "Current password",
|
||||||
"auth.changePassword.mfaCode": "Two-factor code",
|
"auth.changePassword.mfaCode": "Two-factor code",
|
||||||
|
@ -759,12 +765,23 @@
|
||||||
"executionView.onPaste.title": "Cannot paste here",
|
"executionView.onPaste.title": "Cannot paste here",
|
||||||
"executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow",
|
"executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow",
|
||||||
"executionView.notFound.message": "Execution with id '{executionId}' could not be found!",
|
"executionView.notFound.message": "Execution with id '{executionId}' could not be found!",
|
||||||
|
"executionAnnotationView.data.notFound": "Show important data from executions here by adding an <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.executiondata/\">execution data</a> node to your workflow",
|
||||||
|
"executionAnnotationView.vote.error": "Unable to save annotation vote",
|
||||||
|
"executionAnnotationView.tag.error": "Unable to save annotation tags",
|
||||||
|
"executionAnnotationView.addTag": "Add tag",
|
||||||
|
"executionAnnotationView.chooseOrCreateATag": "Choose or create a tag",
|
||||||
|
"executionsFilter.annotation.tags": "Execution tags",
|
||||||
|
"executionsFilter.annotation.rating": "Rating",
|
||||||
|
"executionsFilter.annotation.rating.all": "Any rating",
|
||||||
|
"executionsFilter.annotation.rating.good": "Good",
|
||||||
|
"executionsFilter.annotation.rating.bad": "Bad",
|
||||||
|
"executionsFilter.annotation.selectVoteFilter": "Select Rating",
|
||||||
"executionsFilter.selectStatus": "Select Status",
|
"executionsFilter.selectStatus": "Select Status",
|
||||||
"executionsFilter.selectWorkflow": "Select Workflow",
|
"executionsFilter.selectWorkflow": "Select Workflow",
|
||||||
"executionsFilter.start": "Execution start",
|
"executionsFilter.start": "Execution start",
|
||||||
"executionsFilter.startDate": "Earliest",
|
"executionsFilter.startDate": "Earliest",
|
||||||
"executionsFilter.endDate": "Latest",
|
"executionsFilter.endDate": "Latest",
|
||||||
"executionsFilter.savedData": "Custom data (saved in execution)",
|
"executionsFilter.savedData": "Highlighted data",
|
||||||
"executionsFilter.savedDataKey": "Key",
|
"executionsFilter.savedDataKey": "Key",
|
||||||
"executionsFilter.savedDataKeyPlaceholder": "ID",
|
"executionsFilter.savedDataKeyPlaceholder": "ID",
|
||||||
"executionsFilter.savedDataValue": "Value (exact match)",
|
"executionsFilter.savedDataValue": "Value (exact match)",
|
||||||
|
@ -772,7 +789,7 @@
|
||||||
"executionsFilter.reset": "Reset all",
|
"executionsFilter.reset": "Reset all",
|
||||||
"executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}",
|
"executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}",
|
||||||
"executionsFilter.customData.inputTooltip.link": "View plans",
|
"executionsFilter.customData.inputTooltip.link": "View plans",
|
||||||
"executionsFilter.customData.docsTooltip": "Filter executions by data that you have explicitly saved in them (by calling $execution.customData.set(key, value)). {link}",
|
"executionsFilter.customData.docsTooltip": "Filter executions by data you have saved in them using an ‘Execution Data’ node. {link}",
|
||||||
"executionsFilter.customData.docsTooltip.link": "More info",
|
"executionsFilter.customData.docsTooltip.link": "More info",
|
||||||
"expressionEdit.anythingInside": "Anything inside ",
|
"expressionEdit.anythingInside": "Anything inside ",
|
||||||
"expressionEdit.isJavaScript": " is JavaScript.",
|
"expressionEdit.isJavaScript": " is JavaScript.",
|
||||||
|
@ -965,6 +982,8 @@
|
||||||
"ndv.httpRequest.credentialOnly.docsNotice": "Use the <a target=\"_blank\" href=\"{docsUrl}\">{nodeName} docs</a> to construct your request. We'll take care of the authentication part if you add a {nodeName} credential below.",
|
"ndv.httpRequest.credentialOnly.docsNotice": "Use the <a target=\"_blank\" href=\"{docsUrl}\">{nodeName} docs</a> to construct your request. We'll take care of the authentication part if you add a {nodeName} credential below.",
|
||||||
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
|
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
|
||||||
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
|
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
|
||||||
|
"noAnnotationTagsView.title": "Organize your executions",
|
||||||
|
"noAnnotationTagsView.description": "Execution tags help you label and identify different classes of execution. Plus once you tag an execution, 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.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
|
||||||
"node.activateDeactivateNode": "Activate/Deactivate Node",
|
"node.activateDeactivateNode": "Activate/Deactivate Node",
|
||||||
"node.changeColor": "Change color",
|
"node.changeColor": "Change color",
|
||||||
|
@ -1914,6 +1933,8 @@
|
||||||
"tagsManager.couldNotDeleteTag": "Could not delete tag",
|
"tagsManager.couldNotDeleteTag": "Could not delete tag",
|
||||||
"tagsManager.done": "Done",
|
"tagsManager.done": "Done",
|
||||||
"tagsManager.manageTags": "Manage tags",
|
"tagsManager.manageTags": "Manage tags",
|
||||||
|
"tagsManager.showError.onFetch.title": "Could not fetch tags",
|
||||||
|
"tagsManager.showError.onFetch.message": "A problem occurred when trying to fetch tags",
|
||||||
"tagsManager.showError.onCreate.message": "A problem occurred when trying to create the tag '{escapedName}'",
|
"tagsManager.showError.onCreate.message": "A problem occurred when trying to create the tag '{escapedName}'",
|
||||||
"tagsManager.showError.onCreate.title": "Could not create tag",
|
"tagsManager.showError.onCreate.title": "Could not create tag",
|
||||||
"tagsManager.showError.onDelete.message": "A problem occurred when trying to delete the tag '{escapedName}'",
|
"tagsManager.showError.onDelete.message": "A problem occurred when trying to delete the tag '{escapedName}'",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { IDataObject, ExecutionSummary } from 'n8n-workflow';
|
import type { IDataObject, ExecutionSummary, AnnotationVote } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
ExecutionFilterType,
|
ExecutionFilterType,
|
||||||
ExecutionsQueryFilter,
|
ExecutionsQueryFilter,
|
||||||
|
@ -82,9 +82,12 @@ export const useExecutionsStore = defineStore('executions', () => {
|
||||||
const allExecutions = computed(() => [...currentExecutions.value, ...executions.value]);
|
const allExecutions = computed(() => [...currentExecutions.value, ...executions.value]);
|
||||||
|
|
||||||
function addExecution(execution: ExecutionSummaryWithScopes) {
|
function addExecution(execution: ExecutionSummaryWithScopes) {
|
||||||
executionsById.value[execution.id] = {
|
executionsById.value = {
|
||||||
...execution,
|
...executionsById.value,
|
||||||
mode: execution.mode,
|
[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> {
|
async function stopCurrentExecution(executionId: string): Promise<IExecutionsStopData> {
|
||||||
return await makeRestApiRequest(
|
return await makeRestApiRequest(
|
||||||
rootStore.restApiContext,
|
rootStore.restApiContext,
|
||||||
|
@ -245,6 +266,7 @@ export const useExecutionsStore = defineStore('executions', () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
|
annotateExecution,
|
||||||
executionsById,
|
executionsById,
|
||||||
executions,
|
executions,
|
||||||
executionsCount,
|
executionsCount,
|
||||||
|
|
|
@ -14,6 +14,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
||||||
const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({
|
const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({
|
||||||
workflow: {},
|
workflow: {},
|
||||||
tag: {},
|
tag: {},
|
||||||
|
annotationTag: {},
|
||||||
user: {},
|
user: {},
|
||||||
credential: {},
|
credential: {},
|
||||||
variable: {},
|
variable: {},
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import * as tagsApi from '@/api/tags';
|
import { createTagsApi } from '@/api/tags';
|
||||||
import { STORES } from '@/constants';
|
import { STORES } from '@/constants';
|
||||||
import type { ITag } from '@/Interface';
|
import type { ITag } from '@/Interface';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
@ -6,109 +6,129 @@ import { useRootStore } from './root.store';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useWorkflowsStore } from './workflows.store';
|
import { useWorkflowsStore } from './workflows.store';
|
||||||
|
|
||||||
export const useTagsStore = defineStore(STORES.TAGS, () => {
|
const apiMapping = {
|
||||||
const tagsById = ref<Record<string, ITag>>({});
|
[STORES.TAGS]: createTagsApi('/tags'),
|
||||||
const loading = ref(false);
|
[STORES.ANNOTATION_TAGS]: createTagsApi('/annotation-tags'),
|
||||||
const fetchedAll = ref(false);
|
};
|
||||||
const fetchedUsageCount = ref(false);
|
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => {
|
||||||
const workflowsStore = useWorkflowsStore();
|
const tagsApi = apiMapping[id];
|
||||||
|
|
||||||
// Computed
|
return defineStore(
|
||||||
|
id,
|
||||||
|
() => {
|
||||||
|
const tagsById = ref<Record<string, ITag>>({});
|
||||||
|
const loading = ref(false);
|
||||||
|
const fetchedAll = ref(false);
|
||||||
|
const fetchedUsageCount = ref(false);
|
||||||
|
|
||||||
const allTags = computed(() => {
|
const rootStore = useRootStore();
|
||||||
return Object.values(tagsById.value).sort((a, b) => a.name.localeCompare(b.name));
|
const workflowsStore = useWorkflowsStore();
|
||||||
});
|
|
||||||
|
|
||||||
const isLoading = computed(() => loading.value);
|
// Computed
|
||||||
|
|
||||||
const hasTags = computed(() => Object.keys(tagsById.value).length > 0);
|
const allTags = computed(() => {
|
||||||
|
return Object.values(tagsById.value).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
|
||||||
// Methods
|
const isLoading = computed(() => loading.value);
|
||||||
|
|
||||||
const setAllTags = (loadedTags: ITag[]) => {
|
const hasTags = computed(() => Object.keys(tagsById.value).length > 0);
|
||||||
tagsById.value = loadedTags.reduce((accu: { [id: string]: ITag }, tag: ITag) => {
|
|
||||||
accu[tag.id] = tag;
|
|
||||||
|
|
||||||
return accu;
|
// Methods
|
||||||
}, {});
|
|
||||||
fetchedAll.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const upsertTags = (toUpsertTags: ITag[]) => {
|
const setAllTags = (loadedTags: ITag[]) => {
|
||||||
toUpsertTags.forEach((toUpsertTag) => {
|
tagsById.value = loadedTags.reduce((accu: { [id: string]: ITag }, tag: ITag) => {
|
||||||
const tagId = toUpsertTag.id;
|
accu[tag.id] = tag;
|
||||||
const currentTag = tagsById.value[tagId];
|
|
||||||
if (currentTag) {
|
|
||||||
const newTag = {
|
|
||||||
...currentTag,
|
|
||||||
...toUpsertTag,
|
|
||||||
};
|
|
||||||
tagsById.value = {
|
|
||||||
...tagsById.value,
|
|
||||||
[tagId]: newTag,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
tagsById.value = {
|
|
||||||
...tagsById.value,
|
|
||||||
[tagId]: toUpsertTag,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTag = (id: string) => {
|
return accu;
|
||||||
const { [id]: deleted, ...rest } = tagsById.value;
|
}, {});
|
||||||
tagsById.value = rest;
|
fetchedAll.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAll = async (params?: { force?: boolean; withUsageCount?: boolean }) => {
|
const upsertTags = (toUpsertTags: ITag[]) => {
|
||||||
const { force = false, withUsageCount = false } = params || {};
|
toUpsertTags.forEach((toUpsertTag) => {
|
||||||
if (!force && fetchedAll.value && fetchedUsageCount.value === withUsageCount) {
|
const tagId = toUpsertTag.id;
|
||||||
return Object.values(tagsById.value);
|
const currentTag = tagsById.value[tagId];
|
||||||
}
|
if (currentTag) {
|
||||||
|
const newTag = {
|
||||||
|
...currentTag,
|
||||||
|
...toUpsertTag,
|
||||||
|
};
|
||||||
|
tagsById.value = {
|
||||||
|
...tagsById.value,
|
||||||
|
[tagId]: newTag,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
tagsById.value = {
|
||||||
|
...tagsById.value,
|
||||||
|
[tagId]: toUpsertTag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
loading.value = true;
|
const deleteTag = (id: string) => {
|
||||||
const retrievedTags = await tagsApi.getTags(rootStore.restApiContext, Boolean(withUsageCount));
|
const { [id]: deleted, ...rest } = tagsById.value;
|
||||||
setAllTags(retrievedTags);
|
tagsById.value = rest;
|
||||||
loading.value = false;
|
};
|
||||||
return retrievedTags;
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = async (name: string) => {
|
const fetchAll = async (params?: { force?: boolean; withUsageCount?: boolean }) => {
|
||||||
const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name });
|
const { force = false, withUsageCount = false } = params || {};
|
||||||
upsertTags([createdTag]);
|
if (!force && fetchedAll.value && fetchedUsageCount.value === withUsageCount) {
|
||||||
return createdTag;
|
return Object.values(tagsById.value);
|
||||||
};
|
}
|
||||||
|
|
||||||
const rename = async ({ id, name }: { id: string; name: string }) => {
|
loading.value = true;
|
||||||
const updatedTag = await tagsApi.updateTag(rootStore.restApiContext, id, { name });
|
const retrievedTags = await tagsApi.getTags(
|
||||||
upsertTags([updatedTag]);
|
rootStore.restApiContext,
|
||||||
return updatedTag;
|
Boolean(withUsageCount),
|
||||||
};
|
);
|
||||||
|
setAllTags(retrievedTags);
|
||||||
|
loading.value = false;
|
||||||
|
return retrievedTags;
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTagById = async (id: string) => {
|
const create = async (name: string) => {
|
||||||
const deleted = await tagsApi.deleteTag(rootStore.restApiContext, id);
|
const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name });
|
||||||
|
upsertTags([createdTag]);
|
||||||
|
return createdTag;
|
||||||
|
};
|
||||||
|
|
||||||
if (deleted) {
|
const rename = async ({ id, name }: { id: string; name: string }) => {
|
||||||
deleteTag(id);
|
const updatedTag = await tagsApi.updateTag(rootStore.restApiContext, id, { name });
|
||||||
workflowsStore.removeWorkflowTagId(id);
|
upsertTags([updatedTag]);
|
||||||
}
|
return updatedTag;
|
||||||
|
};
|
||||||
|
|
||||||
return deleted;
|
const deleteTagById = async (id: string) => {
|
||||||
};
|
const deleted = await tagsApi.deleteTag(rootStore.restApiContext, id);
|
||||||
|
|
||||||
return {
|
if (deleted) {
|
||||||
allTags,
|
deleteTag(id);
|
||||||
isLoading,
|
workflowsStore.removeWorkflowTagId(id);
|
||||||
hasTags,
|
}
|
||||||
tagsById,
|
|
||||||
fetchAll,
|
return deleted;
|
||||||
create,
|
};
|
||||||
rename,
|
|
||||||
deleteTagById,
|
return {
|
||||||
upsertTags,
|
allTags,
|
||||||
deleteTag,
|
isLoading,
|
||||||
};
|
hasTags,
|
||||||
});
|
tagsById,
|
||||||
|
fetchAll,
|
||||||
|
create,
|
||||||
|
rename,
|
||||||
|
deleteTagById,
|
||||||
|
upsertTags,
|
||||||
|
deleteTag,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTagsStore = createTagsStore(STORES.TAGS);
|
||||||
|
|
||||||
|
export const useAnnotationTagsStore = createTagsStore(STORES.ANNOTATION_TAGS);
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
PERSONALIZATION_MODAL_KEY,
|
PERSONALIZATION_MODAL_KEY,
|
||||||
STORES,
|
STORES,
|
||||||
TAGS_MANAGER_MODAL_KEY,
|
TAGS_MANAGER_MODAL_KEY,
|
||||||
|
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||||
NPS_SURVEY_MODAL_KEY,
|
NPS_SURVEY_MODAL_KEY,
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
|
@ -108,6 +109,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||||
PERSONALIZATION_MODAL_KEY,
|
PERSONALIZATION_MODAL_KEY,
|
||||||
INVITE_USER_MODAL_KEY,
|
INVITE_USER_MODAL_KEY,
|
||||||
TAGS_MANAGER_MODAL_KEY,
|
TAGS_MANAGER_MODAL_KEY,
|
||||||
|
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||||
NPS_SURVEY_MODAL_KEY,
|
NPS_SURVEY_MODAL_KEY,
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
WORKFLOW_LM_CHAT_MODAL_KEY,
|
||||||
|
|
|
@ -9,7 +9,9 @@ export function getDefaultExecutionFilters(): ExecutionFilterType {
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
annotationTags: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
|
vote: 'all',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +27,14 @@ export const executionFilterToQueryFilter = (
|
||||||
queryFilter.tags = filter.tags;
|
queryFilter.tags = filter.tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isEmpty(filter.annotationTags)) {
|
||||||
|
queryFilter.annotationTags = filter.annotationTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.vote !== 'all') {
|
||||||
|
queryFilter.vote = filter.vote;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isEmpty(filter.metadata)) {
|
if (!isEmpty(filter.metadata)) {
|
||||||
queryFilter.metadata = filter.metadata;
|
queryFilter.metadata = filter.metadata;
|
||||||
}
|
}
|
||||||
|
@ -54,6 +64,7 @@ export const executionFilterToQueryFilter = (
|
||||||
queryFilter.status = ['canceled'];
|
queryFilter.status = ['canceled'];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryFilter;
|
return queryFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import ResourcesListLayout, { type IResource } from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout, { type IResource } from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import WorkflowCard from '@/components/WorkflowCard.vue';
|
import WorkflowCard from '@/components/WorkflowCard.vue';
|
||||||
|
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
||||||
import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
|
import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
|
||||||
import type { ITag, IUser, IWorkflowDb } from '@/Interface';
|
import type { ITag, IUser, IWorkflowDb } from '@/Interface';
|
||||||
import TagsDropdown from '@/components/TagsDropdown.vue';
|
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
@ -36,7 +36,7 @@ const WorkflowsView = defineComponent({
|
||||||
components: {
|
components: {
|
||||||
ResourcesListLayout,
|
ResourcesListLayout,
|
||||||
WorkflowCard,
|
WorkflowCard,
|
||||||
TagsDropdown,
|
WorkflowTagsDropdown,
|
||||||
ProjectTabs,
|
ProjectTabs,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
@ -432,7 +432,7 @@ export default WorkflowsView;
|
||||||
color="text-base"
|
color="text-base"
|
||||||
class="mb-3xs"
|
class="mb-3xs"
|
||||||
/>
|
/>
|
||||||
<TagsDropdown
|
<WorkflowTagsDropdown
|
||||||
:placeholder="$locale.baseText('workflowOpen.filterWorkflows')"
|
:placeholder="$locale.baseText('workflowOpen.filterWorkflows')"
|
||||||
:model-value="filters.tags"
|
:model-value="filters.tags"
|
||||||
:create-enabled="false"
|
:create-enabled="false"
|
||||||
|
|
|
@ -25,7 +25,7 @@ export class ExecutionData implements INodeType {
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
displayName:
|
displayName:
|
||||||
"Use this node to save fields you want to use later to easily find an execution (e.g. a user ID). You'll be able to search by this data in the 'executions' tab.<br>This feature is available on our Pro and Enterprise plans. <a href='https://n8n.io/pricing/' target='_blank'>More Info</a>.",
|
"Save important data using this node. It will be displayed on each execution for easy reference and you can filter by it.<br />Filtering is available on Pro and Enterprise plans. <a href='https://n8n.io/pricing/' target='_blank'>More Info</a>",
|
||||||
name: 'notice',
|
name: 'notice',
|
||||||
type: 'notice',
|
type: 'notice',
|
||||||
default: '',
|
default: '',
|
||||||
|
@ -38,9 +38,9 @@ export class ExecutionData implements INodeType {
|
||||||
noDataExpression: true,
|
noDataExpression: true,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: 'Save Execution Data for Search',
|
name: 'Save Highlight Data (for Search/review)',
|
||||||
value: 'save',
|
value: 'save',
|
||||||
action: 'Save execution data for search',
|
action: 'Save Highlight Data (for search/review)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -2435,6 +2435,8 @@ export interface NodeExecutionWithMetadata extends INodeExecutionData {
|
||||||
pairedItem: IPairedItemData | IPairedItemData[];
|
pairedItem: IPairedItemData | IPairedItemData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AnnotationVote = 'up' | 'down';
|
||||||
|
|
||||||
export interface ExecutionSummary {
|
export interface ExecutionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
finished?: boolean;
|
finished?: boolean;
|
||||||
|
@ -2452,6 +2454,13 @@ export interface ExecutionSummary {
|
||||||
nodeExecutionStatus?: {
|
nodeExecutionStatus?: {
|
||||||
[key: string]: IExecutionSummaryNodeExecutionResult;
|
[key: string]: IExecutionSummaryNodeExecutionResult;
|
||||||
};
|
};
|
||||||
|
annotation?: {
|
||||||
|
vote: AnnotationVote;
|
||||||
|
tags: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExecutionSummaryNodeExecutionResult {
|
export interface IExecutionSummaryNodeExecutionResult {
|
||||||
|
|
Loading…
Reference in a new issue