mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 16:44:07 -08:00
Merge remote-tracking branch 'origin/master' into ADO-2729/feature-set-field-default-value-of-added-node-based-on-previous
This commit is contained in:
commit
21f126cc46
|
@ -62,6 +62,15 @@ describe('BuiltInsParser', () => {
|
||||||
|
|
||||||
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
|
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([['items'], ['item']])(
|
||||||
|
'should mark input as needed when %s is used',
|
||||||
|
(identifier) => {
|
||||||
|
const state = parseAndExpectOk(`return ${identifier};`);
|
||||||
|
|
||||||
|
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('$(...)', () => {
|
describe('$(...)', () => {
|
||||||
|
@ -135,6 +144,13 @@ describe('BuiltInsParser', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('$node', () => {
|
||||||
|
it('should require all nodes when $node is used', () => {
|
||||||
|
const state = parseAndExpectOk('return $node["name"];');
|
||||||
|
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ECMAScript syntax', () => {
|
describe('ECMAScript syntax', () => {
|
||||||
describe('ES2020', () => {
|
describe('ES2020', () => {
|
||||||
it('should parse optional chaining', () => {
|
it('should parse optional chaining', () => {
|
||||||
|
|
|
@ -125,8 +125,19 @@ export class BuiltInsParser {
|
||||||
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
|
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
|
||||||
if (node.name === '$env') {
|
if (node.name === '$env') {
|
||||||
state.markEnvAsNeeded();
|
state.markEnvAsNeeded();
|
||||||
} else if (node.name === '$input' || node.name === '$json') {
|
} else if (
|
||||||
|
node.name === '$input' ||
|
||||||
|
node.name === '$json' ||
|
||||||
|
node.name === 'items' ||
|
||||||
|
// item is deprecated but we still need to support it
|
||||||
|
node.name === 'item'
|
||||||
|
) {
|
||||||
state.markInputAsNeeded();
|
state.markInputAsNeeded();
|
||||||
|
} else if (node.name === '$node') {
|
||||||
|
// $node is legacy way of accessing any node's output. We need to
|
||||||
|
// support it for backward compatibility, but we're not gonna
|
||||||
|
// implement any optimizations
|
||||||
|
state.markNeedsAllNodes();
|
||||||
} else if (node.name === '$execution') {
|
} else if (node.name === '$execution') {
|
||||||
state.markExecutionAsNeeded();
|
state.markExecutionAsNeeded();
|
||||||
} else if (node.name === '$prevNode') {
|
} else if (node.name === '$prevNode') {
|
||||||
|
|
|
@ -31,6 +31,11 @@ export class ExecutionEntity {
|
||||||
@PrimaryColumn({ transformer: idStringifier })
|
@PrimaryColumn({ transformer: idStringifier })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the execution finished sucessfully.
|
||||||
|
*
|
||||||
|
* @deprecated Use `status` instead
|
||||||
|
*/
|
||||||
@Column()
|
@Column()
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { Settings } from './settings';
|
||||||
import { SharedCredentials } from './shared-credentials';
|
import { SharedCredentials } from './shared-credentials';
|
||||||
import { SharedWorkflow } from './shared-workflow';
|
import { SharedWorkflow } from './shared-workflow';
|
||||||
import { TagEntity } from './tag-entity';
|
import { TagEntity } from './tag-entity';
|
||||||
import { TestDefinition } from './test-definition';
|
import { TestDefinition } from './test-definition.ee';
|
||||||
import { User } from './user';
|
import { User } from './user';
|
||||||
import { Variables } from './variables';
|
import { Variables } from './variables';
|
||||||
import { WebhookEntity } from './webhook-entity';
|
import { WebhookEntity } from './webhook-entity';
|
||||||
|
|
|
@ -4,7 +4,6 @@ import {
|
||||||
Generated,
|
Generated,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToOne,
|
|
||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
RelationId,
|
RelationId,
|
||||||
} from '@n8n/typeorm';
|
} from '@n8n/typeorm';
|
||||||
|
@ -31,7 +30,9 @@ export class TestDefinition extends WithTimestamps {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
@Column({ length: 255 })
|
@Column({ length: 255 })
|
||||||
@Length(1, 255, { message: 'Test name must be $constraint1 to $constraint2 characters long.' })
|
@Length(1, 255, {
|
||||||
|
message: 'Test definition name must be $constraint1 to $constraint2 characters long.',
|
||||||
|
})
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -56,6 +57,9 @@ export class TestDefinition extends WithTimestamps {
|
||||||
* Relation to the annotation tag associated with the test
|
* Relation to the annotation tag associated with the test
|
||||||
* This tag will be used to select the test cases to run from previous executions
|
* This tag will be used to select the test cases to run from previous executions
|
||||||
*/
|
*/
|
||||||
@OneToOne('AnnotationTagEntity', 'test')
|
@ManyToOne('AnnotationTagEntity', 'test')
|
||||||
annotationTag: AnnotationTagEntity;
|
annotationTag: AnnotationTagEntity;
|
||||||
|
|
||||||
|
@RelationId((test: TestDefinition) => test.annotationTag)
|
||||||
|
annotationTagId: string;
|
||||||
}
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
|
||||||
|
import { DataSource, In, Repository } from '@n8n/typeorm';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
import { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||||
|
import type { ListQuery } from '@/requests';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class TestDefinitionRepository extends Repository<TestDefinition> {
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
super(TestDefinition, dataSource.manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMany(accessibleWorkflowIds: string[], options?: ListQuery.Options) {
|
||||||
|
if (accessibleWorkflowIds.length === 0) return { tests: [], count: 0 };
|
||||||
|
|
||||||
|
const where: FindOptionsWhere<TestDefinition> = {
|
||||||
|
...options?.filter,
|
||||||
|
workflow: {
|
||||||
|
id: In(accessibleWorkflowIds),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const findManyOptions: FindManyOptions<TestDefinition> = {
|
||||||
|
where,
|
||||||
|
relations: ['annotationTag'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.take) {
|
||||||
|
findManyOptions.skip = options.skip;
|
||||||
|
findManyOptions.take = options.take;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [testDefinitions, count] = await this.findAndCount(findManyOptions);
|
||||||
|
|
||||||
|
return { testDefinitions, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(id: number, accessibleWorkflowIds: string[]) {
|
||||||
|
return await this.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workflow: {
|
||||||
|
id: In(accessibleWorkflowIds),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
relations: ['annotationTag'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteById(id: number, accessibleWorkflowIds: string[]) {
|
||||||
|
return await this.delete({
|
||||||
|
id,
|
||||||
|
workflow: {
|
||||||
|
id: In(accessibleWorkflowIds),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
17
packages/cli/src/evaluation/test-definition.schema.ts
Normal file
17
packages/cli/src/evaluation/test-definition.schema.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const testDefinitionCreateRequestBodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
workflowId: z.string().min(1),
|
||||||
|
evaluationWorkflowId: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const testDefinitionPatchRequestBodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
evaluationWorkflowId: z.string().min(1).optional(),
|
||||||
|
annotationTagId: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
124
packages/cli/src/evaluation/test-definition.service.ee.ts
Normal file
124
packages/cli/src/evaluation/test-definition.service.ee.ts
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||||
|
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
|
||||||
|
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
||||||
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
import { validateEntity } from '@/generic-helpers';
|
||||||
|
import type { ListQuery } from '@/requests';
|
||||||
|
|
||||||
|
type TestDefinitionLike = Omit<
|
||||||
|
Partial<TestDefinition>,
|
||||||
|
'workflow' | 'evaluationWorkflow' | 'annotationTag'
|
||||||
|
> & {
|
||||||
|
workflow?: { id: string };
|
||||||
|
evaluationWorkflow?: { id: string };
|
||||||
|
annotationTag?: { id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class TestDefinitionService {
|
||||||
|
constructor(
|
||||||
|
private testDefinitionRepository: TestDefinitionRepository,
|
||||||
|
private annotationTagRepository: AnnotationTagRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private toEntityLike(attrs: {
|
||||||
|
name?: string;
|
||||||
|
workflowId?: string;
|
||||||
|
evaluationWorkflowId?: string;
|
||||||
|
annotationTagId?: string;
|
||||||
|
id?: number;
|
||||||
|
}) {
|
||||||
|
const entity: TestDefinitionLike = {};
|
||||||
|
|
||||||
|
if (attrs.id) {
|
||||||
|
entity.id = attrs.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.name) {
|
||||||
|
entity.name = attrs.name?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.workflowId) {
|
||||||
|
entity.workflow = {
|
||||||
|
id: attrs.workflowId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.evaluationWorkflowId) {
|
||||||
|
entity.evaluationWorkflow = {
|
||||||
|
id: attrs.evaluationWorkflowId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.annotationTagId) {
|
||||||
|
entity.annotationTag = {
|
||||||
|
id: attrs.annotationTagId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
toEntity(attrs: {
|
||||||
|
name?: string;
|
||||||
|
workflowId?: string;
|
||||||
|
evaluationWorkflowId?: string;
|
||||||
|
annotationTagId?: string;
|
||||||
|
id?: number;
|
||||||
|
}) {
|
||||||
|
const entity = this.toEntityLike(attrs);
|
||||||
|
return this.testDefinitionRepository.create(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number, accessibleWorkflowIds: string[]) {
|
||||||
|
return await this.testDefinitionRepository.getOne(id, accessibleWorkflowIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(test: TestDefinition) {
|
||||||
|
await validateEntity(test);
|
||||||
|
|
||||||
|
return await this.testDefinitionRepository.save(test);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, attrs: TestDefinitionLike) {
|
||||||
|
if (attrs.name) {
|
||||||
|
const updatedTest = this.toEntity(attrs);
|
||||||
|
await validateEntity(updatedTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the annotation tag exists
|
||||||
|
if (attrs.annotationTagId) {
|
||||||
|
const annotationTagExists = await this.annotationTagRepository.exists({
|
||||||
|
where: {
|
||||||
|
id: attrs.annotationTagId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!annotationTagExists) {
|
||||||
|
throw new BadRequestError('Annotation tag not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the test definition
|
||||||
|
const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs));
|
||||||
|
|
||||||
|
if (queryResult.affected === 0) {
|
||||||
|
throw new NotFoundError('Test definition not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: number, accessibleWorkflowIds: string[]) {
|
||||||
|
const deleteResult = await this.testDefinitionRepository.deleteById(id, accessibleWorkflowIds);
|
||||||
|
|
||||||
|
if (deleteResult.affected === 0) {
|
||||||
|
throw new NotFoundError('Test definition not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMany(options: ListQuery.Options, accessibleWorkflowIds: string[] = []) {
|
||||||
|
return await this.testDefinitionRepository.getMany(accessibleWorkflowIds, options);
|
||||||
|
}
|
||||||
|
}
|
138
packages/cli/src/evaluation/test-definitions.controller.ee.ts
Normal file
138
packages/cli/src/evaluation/test-definitions.controller.ee.ts
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import express from 'express';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
|
import { Get, Post, Patch, RestController, Delete } from '@/decorators';
|
||||||
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||||
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
import {
|
||||||
|
testDefinitionCreateRequestBodySchema,
|
||||||
|
testDefinitionPatchRequestBodySchema,
|
||||||
|
} from '@/evaluation/test-definition.schema';
|
||||||
|
import { listQueryMiddleware } from '@/middlewares';
|
||||||
|
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
|
||||||
|
import { isPositiveInteger } from '@/utils';
|
||||||
|
|
||||||
|
import { TestDefinitionService } from './test-definition.service.ee';
|
||||||
|
import { TestDefinitionsRequest } from './test-definitions.types.ee';
|
||||||
|
|
||||||
|
@RestController('/evaluation/test-definitions')
|
||||||
|
export class TestDefinitionsController {
|
||||||
|
private validateId(id: string) {
|
||||||
|
if (!isPositiveInteger(id)) {
|
||||||
|
throw new BadRequestError('Test ID is not a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly testDefinitionService: TestDefinitionService) {}
|
||||||
|
|
||||||
|
@Get('/', { middlewares: listQueryMiddleware })
|
||||||
|
async getMany(req: TestDefinitionsRequest.GetMany) {
|
||||||
|
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||||
|
|
||||||
|
return await this.testDefinitionService.getMany(
|
||||||
|
req.listQueryOptions,
|
||||||
|
userAccessibleWorkflowIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/:id')
|
||||||
|
async getOne(req: TestDefinitionsRequest.GetOne) {
|
||||||
|
const testDefinitionId = this.validateId(req.params.id);
|
||||||
|
|
||||||
|
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||||
|
|
||||||
|
const testDefinition = await this.testDefinitionService.findOne(
|
||||||
|
testDefinitionId,
|
||||||
|
userAccessibleWorkflowIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!testDefinition) throw new NotFoundError('Test definition not found');
|
||||||
|
|
||||||
|
return testDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/')
|
||||||
|
async create(req: TestDefinitionsRequest.Create, res: express.Response) {
|
||||||
|
const bodyParseResult = testDefinitionCreateRequestBodySchema.safeParse(req.body);
|
||||||
|
if (!bodyParseResult.success) {
|
||||||
|
res.status(400).json({ errors: bodyParseResult.error.errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||||
|
|
||||||
|
if (!userAccessibleWorkflowIds.includes(req.body.workflowId)) {
|
||||||
|
throw new ForbiddenError('User does not have access to the workflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.body.evaluationWorkflowId &&
|
||||||
|
!userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenError('User does not have access to the evaluation workflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.testDefinitionService.save(
|
||||||
|
this.testDefinitionService.toEntity(bodyParseResult.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/:id')
|
||||||
|
async delete(req: TestDefinitionsRequest.Delete) {
|
||||||
|
const testDefinitionId = this.validateId(req.params.id);
|
||||||
|
|
||||||
|
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||||
|
|
||||||
|
if (userAccessibleWorkflowIds.length === 0)
|
||||||
|
throw new ForbiddenError('User does not have access to any workflows');
|
||||||
|
|
||||||
|
await this.testDefinitionService.delete(testDefinitionId, userAccessibleWorkflowIds);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('/:id')
|
||||||
|
async patch(req: TestDefinitionsRequest.Patch, res: express.Response) {
|
||||||
|
const testDefinitionId = this.validateId(req.params.id);
|
||||||
|
|
||||||
|
const bodyParseResult = testDefinitionPatchRequestBodySchema.safeParse(req.body);
|
||||||
|
if (!bodyParseResult.success) {
|
||||||
|
res.status(400).json({ errors: bodyParseResult.error.errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||||
|
|
||||||
|
// Fail fast if no workflows are accessible
|
||||||
|
if (userAccessibleWorkflowIds.length === 0)
|
||||||
|
throw new ForbiddenError('User does not have access to any workflows');
|
||||||
|
|
||||||
|
const existingTest = await this.testDefinitionService.findOne(
|
||||||
|
testDefinitionId,
|
||||||
|
userAccessibleWorkflowIds,
|
||||||
|
);
|
||||||
|
if (!existingTest) throw new NotFoundError('Test definition not found');
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.body.evaluationWorkflowId &&
|
||||||
|
!userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenError('User does not have access to the evaluation workflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.testDefinitionService.update(testDefinitionId, req.body);
|
||||||
|
|
||||||
|
// Respond with the updated test definition
|
||||||
|
const testDefinition = await this.testDefinitionService.findOne(
|
||||||
|
testDefinitionId,
|
||||||
|
userAccessibleWorkflowIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(testDefinition, 'Test definition not found');
|
||||||
|
|
||||||
|
return testDefinition;
|
||||||
|
}
|
||||||
|
}
|
33
packages/cli/src/evaluation/test-definitions.types.ee.ts
Normal file
33
packages/cli/src/evaluation/test-definitions.types.ee.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// /test-definitions
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
export declare namespace TestDefinitionsRequest {
|
||||||
|
namespace RouteParams {
|
||||||
|
type TestId = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOne = AuthenticatedRequest<RouteParams.TestId>;
|
||||||
|
|
||||||
|
type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & {
|
||||||
|
listQueryOptions: ListQuery.Options;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Create = AuthenticatedRequest<
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ name: string; workflowId: string; evaluationWorkflowId?: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Patch = AuthenticatedRequest<
|
||||||
|
RouteParams.TestId,
|
||||||
|
{},
|
||||||
|
{ name?: string; evaluationWorkflowId?: string; annotationTagId?: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Delete = AuthenticatedRequest<RouteParams.TestId>;
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import { validate } from 'class-validator';
|
||||||
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||||
import type { TestDefinition } from '@/databases/entities/test-definition';
|
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
|
||||||
|
|
|
@ -135,6 +135,10 @@ export interface IExecutionBase {
|
||||||
startedAt: Date;
|
startedAt: Date;
|
||||||
stoppedAt?: Date; // empty value means execution is still running
|
stoppedAt?: Date; // empty value means execution is still running
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `status` instead
|
||||||
|
*/
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
retryOf?: string; // If it is a retry, the id of the execution it is a retry of.
|
retryOf?: string; // If it is a retry, the id of the execution it is a retry of.
|
||||||
retrySuccessId?: string; // If it failed and a retry did succeed. The id of the successful retry.
|
retrySuccessId?: string; // If it failed and a retry did succeed. The id of the successful retry.
|
||||||
|
|
|
@ -63,6 +63,7 @@ import '@/events/events.controller';
|
||||||
import '@/executions/executions.controller';
|
import '@/executions/executions.controller';
|
||||||
import '@/external-secrets/external-secrets.controller.ee';
|
import '@/external-secrets/external-secrets.controller.ee';
|
||||||
import '@/license/license.controller';
|
import '@/license/license.controller';
|
||||||
|
import '@/evaluation/test-definitions.controller.ee';
|
||||||
import '@/workflows/workflow-history/workflow-history.controller.ee';
|
import '@/workflows/workflow-history/workflow-history.controller.ee';
|
||||||
import '@/workflows/workflows.controller';
|
import '@/workflows/workflows.controller';
|
||||||
|
|
||||||
|
|
|
@ -215,7 +215,7 @@ export class WaitingWebhooks implements IWebhookManager {
|
||||||
workflowData as IWorkflowDb,
|
workflowData as IWorkflowDb,
|
||||||
workflowStartNode,
|
workflowStartNode,
|
||||||
executionMode,
|
executionMode,
|
||||||
undefined,
|
runExecutionData.pushRef,
|
||||||
runExecutionData,
|
runExecutionData,
|
||||||
execution.id,
|
execution.id,
|
||||||
req,
|
req,
|
||||||
|
|
|
@ -495,6 +495,11 @@ function hookFunctionsSave(): IWorkflowExecuteHooks {
|
||||||
retryOf: this.retryOf,
|
retryOf: this.retryOf,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When going into the waiting state, store the pushRef in the execution-data
|
||||||
|
if (fullRunData.waitTill && isManualMode) {
|
||||||
|
fullExecutionData.data.pushRef = this.pushRef;
|
||||||
|
}
|
||||||
|
|
||||||
await updateExistingExecution({
|
await updateExistingExecution({
|
||||||
executionId: this.executionId,
|
executionId: this.executionId,
|
||||||
workflowId: this.workflowData.id,
|
workflowId: this.workflowData.id,
|
||||||
|
|
|
@ -226,6 +226,7 @@
|
||||||
|
|
||||||
.multiselect-checkbox {
|
.multiselect-checkbox {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
min-width: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='checkbox'] {
|
input[type='checkbox'] {
|
||||||
|
|
|
@ -0,0 +1,361 @@
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
||||||
|
import { createAnnotationTags } from '@test-integration/db/executions';
|
||||||
|
|
||||||
|
import { createUserShell } from './../shared/db/users';
|
||||||
|
import { createWorkflow } from './../shared/db/workflows';
|
||||||
|
import * as testDb from './../shared/test-db';
|
||||||
|
import type { SuperAgentTest } from './../shared/types';
|
||||||
|
import * as utils from './../shared/utils/';
|
||||||
|
|
||||||
|
let authOwnerAgent: SuperAgentTest;
|
||||||
|
let workflowUnderTest: WorkflowEntity;
|
||||||
|
let evaluationWorkflow: WorkflowEntity;
|
||||||
|
let otherWorkflow: WorkflowEntity;
|
||||||
|
let ownerShell: User;
|
||||||
|
let annotationTag: AnnotationTagEntity;
|
||||||
|
const testServer = utils.setupTestServer({ endpointGroups: ['evaluation'] });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ownerShell = await createUserShell('global:owner');
|
||||||
|
authOwnerAgent = testServer.authAgentFor(ownerShell);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testDb.truncate(['TestDefinition', 'Workflow', 'AnnotationTag']);
|
||||||
|
|
||||||
|
workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell);
|
||||||
|
evaluationWorkflow = await createWorkflow({ name: 'evaluation-workflow' }, ownerShell);
|
||||||
|
otherWorkflow = await createWorkflow({ name: 'other-workflow' });
|
||||||
|
annotationTag = (await createAnnotationTags(['test-tag']))[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /evaluation/test-definitions', () => {
|
||||||
|
test('should retrieve empty test definitions list', async () => {
|
||||||
|
const resp = await authOwnerAgent.get('/evaluation/test-definitions');
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.count).toBe(0);
|
||||||
|
expect(resp.body.data.testDefinitions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should retrieve test definitions list', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.get('/evaluation/test-definitions');
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data).toEqual({
|
||||||
|
count: 1,
|
||||||
|
testDefinitions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'test',
|
||||||
|
workflowId: workflowUnderTest.id,
|
||||||
|
evaluationWorkflowId: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should retrieve test definitions list with pagination', async () => {
|
||||||
|
// Add a bunch of test definitions
|
||||||
|
const testDefinitions = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: `test-${i}`,
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
testDefinitions.push(newTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Container.get(TestDefinitionRepository).save(testDefinitions);
|
||||||
|
|
||||||
|
// Fetch the first page
|
||||||
|
let resp = await authOwnerAgent.get('/evaluation/test-definitions?take=10');
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.count).toBe(15);
|
||||||
|
expect(resp.body.data.testDefinitions).toHaveLength(10);
|
||||||
|
|
||||||
|
// Fetch the second page
|
||||||
|
resp = await authOwnerAgent.get('/evaluation/test-definitions?take=10&skip=10');
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.count).toBe(15);
|
||||||
|
expect(resp.body.data.testDefinitions).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /evaluation/test-definitions/:id', () => {
|
||||||
|
test('should retrieve test definition', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`);
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.name).toBe('test');
|
||||||
|
expect(resp.body.data.workflowId).toBe(workflowUnderTest.id);
|
||||||
|
expect(resp.body.data.evaluationWorkflowId).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 404 for non-existent test definition', async () => {
|
||||||
|
const resp = await authOwnerAgent.get('/evaluation/test-definitions/123');
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should retrieve test definition with evaluation workflow', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
evaluationWorkflow: { id: evaluationWorkflow.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`);
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.name).toBe('test');
|
||||||
|
expect(resp.body.data.workflowId).toBe(workflowUnderTest.id);
|
||||||
|
expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not retrieve test definition if user does not have access to workflow under test', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: otherWorkflow.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`);
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /evaluation/test-definitions', () => {
|
||||||
|
test('should create test definition', async () => {
|
||||||
|
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
|
||||||
|
name: 'test',
|
||||||
|
workflowId: workflowUnderTest.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.name).toBe('test');
|
||||||
|
expect(resp.body.data.workflowId).toBe(workflowUnderTest.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create test definition with evaluation workflow', async () => {
|
||||||
|
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
|
||||||
|
name: 'test',
|
||||||
|
workflowId: workflowUnderTest.id,
|
||||||
|
evaluationWorkflowId: evaluationWorkflow.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.name).toBe('test');
|
||||||
|
expect(resp.body.data.workflowId).toBe(workflowUnderTest.id);
|
||||||
|
expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return error if name is empty', async () => {
|
||||||
|
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
|
||||||
|
name: '',
|
||||||
|
workflowId: workflowUnderTest.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(400);
|
||||||
|
expect(resp.body.errors).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'too_small',
|
||||||
|
path: ['name'],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return error if user has no access to the workflow', async () => {
|
||||||
|
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
|
||||||
|
name: 'test',
|
||||||
|
workflowId: otherWorkflow.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(403);
|
||||||
|
expect(resp.body.message).toBe('User does not have access to the workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return error if user has no access to the evaluation workflow', async () => {
|
||||||
|
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
|
||||||
|
name: 'test',
|
||||||
|
workflowId: workflowUnderTest.id,
|
||||||
|
evaluationWorkflowId: otherWorkflow.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(403);
|
||||||
|
expect(resp.body.message).toBe('User does not have access to the evaluation workflow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /evaluation/test-definitions/:id', () => {
|
||||||
|
test('should update test definition', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
name: 'updated-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.name).toBe('updated-test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 404 if user has no access to the workflow', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: otherWorkflow.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
name: 'updated-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(404);
|
||||||
|
expect(resp.body.message).toBe('Test definition not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update test definition with evaluation workflow', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
name: 'updated-test',
|
||||||
|
evaluationWorkflowId: evaluationWorkflow.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.name).toBe('updated-test');
|
||||||
|
expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return error if user has no access to the evaluation workflow', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
name: 'updated-test',
|
||||||
|
evaluationWorkflowId: otherWorkflow.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(403);
|
||||||
|
expect(resp.body.message).toBe('User does not have access to the evaluation workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should disallow workflowId', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
name: 'updated-test',
|
||||||
|
workflowId: otherWorkflow.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(400);
|
||||||
|
expect(resp.body.errors).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'unrecognized_keys',
|
||||||
|
keys: ['workflowId'],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update annotationTagId', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
annotationTagId: annotationTag.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.annotationTag.id).toBe(annotationTag.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return error if annotationTagId is invalid', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
annotationTagId: '123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(400);
|
||||||
|
expect(resp.body.message).toBe('Annotation tag not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /evaluation/test-definitions/:id', () => {
|
||||||
|
test('should delete test definition', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.delete(`/evaluation/test-definitions/${newTest.id}`);
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 404 if test definition does not exist', async () => {
|
||||||
|
const resp = await authOwnerAgent.delete('/evaluation/test-definitions/123');
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(404);
|
||||||
|
expect(resp.body.message).toBe('Test definition not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 404 if user has no access to the workflow', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: otherWorkflow.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.delete(`/evaluation/test-definitions/${newTest.id}`);
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(404);
|
||||||
|
expect(resp.body.message).toBe('Test definition not found');
|
||||||
|
});
|
||||||
|
});
|
|
@ -74,6 +74,7 @@ const repositories = [
|
||||||
'SharedCredentials',
|
'SharedCredentials',
|
||||||
'SharedWorkflow',
|
'SharedWorkflow',
|
||||||
'Tag',
|
'Tag',
|
||||||
|
'TestDefinition',
|
||||||
'User',
|
'User',
|
||||||
'Variables',
|
'Variables',
|
||||||
'Webhook',
|
'Webhook',
|
||||||
|
|
|
@ -41,7 +41,8 @@ type EndpointGroup =
|
||||||
| 'project'
|
| 'project'
|
||||||
| 'role'
|
| 'role'
|
||||||
| 'dynamic-node-parameters'
|
| 'dynamic-node-parameters'
|
||||||
| 'apiKeys';
|
| 'apiKeys'
|
||||||
|
| 'evaluation';
|
||||||
|
|
||||||
export interface SetupProps {
|
export interface SetupProps {
|
||||||
endpointGroups?: EndpointGroup[];
|
endpointGroups?: EndpointGroup[];
|
||||||
|
|
|
@ -277,6 +277,10 @@ export const setupTestServer = ({
|
||||||
case 'apiKeys':
|
case 'apiKeys':
|
||||||
await import('@/controllers/api-keys.controller');
|
await import('@/controllers/api-keys.controller');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'evaluation':
|
||||||
|
await import('@/evaluation/test-definitions.controller.ee');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -404,7 +404,12 @@ async function onDrop(value: string, event: MouseEvent) {
|
||||||
data-test-id="code-node-tab-code"
|
data-test-id="code-node-tab-code"
|
||||||
:class="$style.fillHeight"
|
:class="$style.fillHeight"
|
||||||
>
|
>
|
||||||
<DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
|
<DraggableTarget
|
||||||
|
type="mapping"
|
||||||
|
:disabled="!dragAndDropEnabled"
|
||||||
|
:class="$style.fillHeight"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
<template #default="{ activeDrop, droppable }">
|
<template #default="{ activeDrop, droppable }">
|
||||||
<div
|
<div
|
||||||
ref="codeNodeEditorRef"
|
ref="codeNodeEditorRef"
|
||||||
|
@ -437,7 +442,12 @@ async function onDrop(value: string, event: MouseEvent) {
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
||||||
<div v-else :class="$style.fillHeight">
|
<div v-else :class="$style.fillHeight">
|
||||||
<DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
|
<DraggableTarget
|
||||||
|
type="mapping"
|
||||||
|
:disabled="!dragAndDropEnabled"
|
||||||
|
:class="$style.fillHeight"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
<template #default="{ activeDrop, droppable }">
|
<template #default="{ activeDrop, droppable }">
|
||||||
<div
|
<div
|
||||||
ref="codeNodeEditorRef"
|
ref="codeNodeEditorRef"
|
||||||
|
|
|
@ -155,6 +155,7 @@ async function onDrop(expression: string, event: MouseEvent) {
|
||||||
:mapping-enabled="!isReadOnly"
|
:mapping-enabled="!isReadOnly"
|
||||||
:connection-type="NodeConnectionType.Main"
|
:connection-type="NodeConnectionType.Main"
|
||||||
pane-type="input"
|
pane-type="input"
|
||||||
|
context="modal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
86
packages/editor-ui/src/components/InputPanel.test.ts
Normal file
86
packages/editor-ui/src/components/InputPanel.test.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { createTestNode, createTestWorkflow, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import InputPanel, { type Props } from '@/components/InputPanel.vue';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { NodeConnectionType, type IConnections, type INodeExecutionData } from 'n8n-workflow';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { mockedStore } from '../__tests__/utils';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => {
|
||||||
|
return {
|
||||||
|
useRouter: () => ({}),
|
||||||
|
useRoute: () => ({ meta: {} }),
|
||||||
|
RouterLink: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
createTestNode({ id: 'node1', name: 'Node 1' }),
|
||||||
|
createTestNode({ id: 'node2', name: 'Node 2' }),
|
||||||
|
createTestNode({ name: 'Agent' }),
|
||||||
|
createTestNode({ name: 'Tool' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[]) => {
|
||||||
|
const connections: IConnections = {
|
||||||
|
[nodes[0].name]: {
|
||||||
|
[NodeConnectionType.Main]: [
|
||||||
|
[{ node: nodes[1].name, type: NodeConnectionType.Main, index: 0 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[nodes[1].name]: {
|
||||||
|
[NodeConnectionType.Main]: [
|
||||||
|
[{ node: nodes[2].name, type: NodeConnectionType.Main, index: 0 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[nodes[3].name]: {
|
||||||
|
[NodeConnectionType.AiMemory]: [
|
||||||
|
[{ node: nodes[2].name, type: NodeConnectionType.AiMemory, index: 0 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinia = createTestingPinia({
|
||||||
|
stubActions: false,
|
||||||
|
initialState: { [STORES.NDV]: { activeNodeName: props.currentNodeName ?? nodes[1].name } },
|
||||||
|
});
|
||||||
|
setActivePinia(pinia);
|
||||||
|
|
||||||
|
const workflow = createTestWorkflow({ nodes, connections });
|
||||||
|
useWorkflowsStore().setWorkflow(workflow);
|
||||||
|
|
||||||
|
if (pinData) {
|
||||||
|
mockedStore(useWorkflowsStore).pinDataByNodeName.mockReturnValue(pinData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowObject = createTestWorkflowObject({
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createComponentRenderer(InputPanel, {
|
||||||
|
props: {
|
||||||
|
pushRef: 'pushRef',
|
||||||
|
runIndex: 0,
|
||||||
|
currentNodeName: nodes[1].name,
|
||||||
|
workflow: workflowObject,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
InputPanelPinButton: { template: '<button data-test-id="ndv-pin-data"></button>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})({ props });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('InputPanel', () => {
|
||||||
|
it('should render', async () => {
|
||||||
|
const { container, queryByTestId } = render({}, [{ json: { name: 'Test' } }]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByTestId('ndv-data-size-warning')).not.toBeInTheDocument());
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
|
@ -25,7 +25,7 @@ import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
type MappingMode = 'debugging' | 'mapping';
|
type MappingMode = 'debugging' | 'mapping';
|
||||||
|
|
||||||
type Props = {
|
export type Props = {
|
||||||
runIndex: number;
|
runIndex: number;
|
||||||
workflow: Workflow;
|
workflow: Workflow;
|
||||||
pushRef: string;
|
pushRef: string;
|
||||||
|
|
|
@ -1522,7 +1522,11 @@ defineExpose({ enterEditMode });
|
||||||
<slot name="no-output-data">xxx</slot>
|
<slot name="no-output-data">xxx</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="hasNodeRun && !showData" :class="$style.center">
|
<div
|
||||||
|
v-else-if="hasNodeRun && !showData"
|
||||||
|
data-test-id="ndv-data-size-warning"
|
||||||
|
:class="$style.center"
|
||||||
|
>
|
||||||
<N8nText :bold="true" color="text-dark" size="large">{{ tooMuchDataTitle }}</N8nText>
|
<N8nText :bold="true" color="text-dark" size="large">{{ tooMuchDataTitle }}</N8nText>
|
||||||
<N8nText align="center" tag="div"
|
<N8nText align="center" tag="div"
|
||||||
><span
|
><span
|
||||||
|
|
|
@ -34,6 +34,7 @@ type Props = {
|
||||||
paneType: 'input' | 'output';
|
paneType: 'input' | 'output';
|
||||||
connectionType?: NodeConnectionType;
|
connectionType?: NodeConnectionType;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
context?: 'ndv' | 'modal';
|
||||||
};
|
};
|
||||||
|
|
||||||
type SchemaNode = {
|
type SchemaNode = {
|
||||||
|
@ -58,6 +59,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
connectionType: NodeConnectionType.Main,
|
connectionType: NodeConnectionType.Main,
|
||||||
search: '',
|
search: '',
|
||||||
mappingEnabled: false,
|
mappingEnabled: false,
|
||||||
|
context: 'ndv',
|
||||||
});
|
});
|
||||||
|
|
||||||
const draggingPath = ref<string>('');
|
const draggingPath = ref<string>('');
|
||||||
|
@ -381,7 +383,7 @@ watch(
|
||||||
:level="0"
|
:level="0"
|
||||||
:parent="null"
|
:parent="null"
|
||||||
:pane-type="paneType"
|
:pane-type="paneType"
|
||||||
:sub-key="snakeCase(currentNode.node.name)"
|
:sub-key="`${props.context}_${snakeCase(currentNode.node.name)}`"
|
||||||
:mapping-enabled="mappingEnabled"
|
:mapping-enabled="mappingEnabled"
|
||||||
:dragging-path="draggingPath"
|
:dragging-path="draggingPath"
|
||||||
:distance-from-active="currentNode.depth"
|
:distance-from-active="currentNode.depth"
|
||||||
|
@ -427,7 +429,7 @@ watch(
|
||||||
:level="0"
|
:level="0"
|
||||||
:parent="null"
|
:parent="null"
|
||||||
:pane-type="paneType"
|
:pane-type="paneType"
|
||||||
:sub-key="`output_${nodeSchema.type}-0-0`"
|
:sub-key="`${props.context}_output_${nodeSchema.type}-0-0`"
|
||||||
:mapping-enabled="mappingEnabled"
|
:mapping-enabled="mappingEnabled"
|
||||||
:dragging-path="draggingPath"
|
:dragging-path="draggingPath"
|
||||||
:node="node"
|
:node="node"
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { checkExhaustive } from '@/utils/typeGuards';
|
||||||
import { shorten } from '@/utils/typesUtils';
|
import { shorten } from '@/utils/typesUtils';
|
||||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||||
import TextWithHighlights from './TextWithHighlights.vue';
|
import TextWithHighlights from './TextWithHighlights.vue';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
|
@ -95,7 +96,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
|
||||||
:data-depth="level"
|
:data-depth="level"
|
||||||
data-target="mappable"
|
data-target="mappable"
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
|
<FontAwesomeIcon :icon="getIconBySchemaType(schema.type)" size="sm" />
|
||||||
<TextWithHighlights
|
<TextWithHighlights
|
||||||
v-if="isSchemaParentTypeArray"
|
v-if="isSchemaParentTypeArray"
|
||||||
:content="props.parent?.key"
|
:content="props.parent?.key"
|
||||||
|
@ -120,12 +121,12 @@ const getIconBySchemaType = (type: Schema['type']): string => {
|
||||||
|
|
||||||
<input v-if="level > 0 && isSchemaValueArray" :id="subKey" type="checkbox" inert checked />
|
<input v-if="level > 0 && isSchemaValueArray" :id="subKey" type="checkbox" inert checked />
|
||||||
<label v-if="level > 0 && isSchemaValueArray" :class="$style.toggle" :for="subKey">
|
<label v-if="level > 0 && isSchemaValueArray" :class="$style.toggle" :for="subKey">
|
||||||
<font-awesome-icon icon="angle-right" />
|
<FontAwesomeIcon icon="angle-right" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div v-if="isSchemaValueArray" :class="$style.sub">
|
<div v-if="isSchemaValueArray" :class="$style.sub">
|
||||||
<div :class="$style.innerSub">
|
<div :class="$style.innerSub">
|
||||||
<run-data-schema-item
|
<RunDataSchemaItem
|
||||||
v-for="s in schemaArray"
|
v-for="s in schemaArray"
|
||||||
:key="s.key ?? s.type"
|
:key="s.key ?? s.type"
|
||||||
:schema="s"
|
:schema="s"
|
||||||
|
|
|
@ -25,6 +25,7 @@ async function focusEditor(container: Element) {
|
||||||
await waitFor(() => expect(container.querySelector('.cm-line')).toBeInTheDocument());
|
await waitFor(() => expect(container.querySelector('.cm-line')).toBeInTheDocument());
|
||||||
await userEvent.click(container.querySelector('.cm-line') as Element);
|
await userEvent.click(container.querySelector('.cm-line') as Element);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
@ -172,4 +173,23 @@ describe('SqlEditor.vue', () => {
|
||||||
getByTestId(EXPRESSION_OUTPUT_TEST_ID).getElementsByClassName('cm-line').length,
|
getByTestId(EXPRESSION_OUTPUT_TEST_ID).getElementsByClassName('cm-line').length,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep rendered output visible when clicking', async () => {
|
||||||
|
const { getByTestId, queryByTestId, container, baseElement } = renderComponent(SqlEditor, {
|
||||||
|
...DEFAULT_SETUP,
|
||||||
|
props: {
|
||||||
|
...DEFAULT_SETUP.props,
|
||||||
|
modelValue: 'SELECT * FROM users',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Does not hide output when clicking inside the output
|
||||||
|
await focusEditor(container);
|
||||||
|
await userEvent.click(getByTestId(EXPRESSION_OUTPUT_TEST_ID));
|
||||||
|
await waitFor(() => expect(queryByTestId(EXPRESSION_OUTPUT_TEST_ID)).toBeInTheDocument());
|
||||||
|
|
||||||
|
// Does hide output when clicking outside the container
|
||||||
|
await userEvent.click(baseElement);
|
||||||
|
await waitFor(() => expect(queryByTestId(EXPRESSION_OUTPUT_TEST_ID)).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -37,6 +37,7 @@ import {
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
import { onClickOutside } from '@vueuse/core';
|
||||||
|
|
||||||
const SQL_DIALECTS = {
|
const SQL_DIALECTS = {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
|
@ -68,7 +69,10 @@ const emit = defineEmits<{
|
||||||
'update:model-value': [value: string];
|
'update:model-value': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const sqlEditor = ref<HTMLElement>();
|
const container = ref<HTMLDivElement>();
|
||||||
|
const sqlEditor = ref<HTMLDivElement>();
|
||||||
|
const isFocused = ref(false);
|
||||||
|
|
||||||
const extensions = computed(() => {
|
const extensions = computed(() => {
|
||||||
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
|
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
|
||||||
function sqlWithN8nLanguageSupport() {
|
function sqlWithN8nLanguageSupport() {
|
||||||
|
@ -122,7 +126,7 @@ const {
|
||||||
editor,
|
editor,
|
||||||
segments: { all: segments },
|
segments: { all: segments },
|
||||||
readEditorValue,
|
readEditorValue,
|
||||||
hasFocus,
|
hasFocus: editorHasFocus,
|
||||||
} = useExpressionEditor({
|
} = useExpressionEditor({
|
||||||
editorRef: sqlEditor,
|
editorRef: sqlEditor,
|
||||||
editorValue,
|
editorValue,
|
||||||
|
@ -138,6 +142,12 @@ watch(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(editorHasFocus, (focus) => {
|
||||||
|
if (focus) {
|
||||||
|
isFocused.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
watch(segments, () => {
|
watch(segments, () => {
|
||||||
emit('update:model-value', readEditorValue());
|
emit('update:model-value', readEditorValue());
|
||||||
});
|
});
|
||||||
|
@ -154,6 +164,19 @@ onBeforeUnmount(() => {
|
||||||
codeNodeEditorEventBus.off('highlightLine', highlightLine);
|
codeNodeEditorEventBus.off('highlightLine', highlightLine);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onClickOutside(container, (event) => onBlur(event));
|
||||||
|
|
||||||
|
function onBlur(event: FocusEvent | KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event?.target instanceof Element &&
|
||||||
|
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
|
||||||
|
) {
|
||||||
|
return; // prevent blur on resizing
|
||||||
|
}
|
||||||
|
|
||||||
|
isFocused.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function line(lineNumber: number): Line | null {
|
function line(lineNumber: number): Line | null {
|
||||||
try {
|
try {
|
||||||
return editor.value?.state.doc.line(lineNumber) ?? null;
|
return editor.value?.state.doc.line(lineNumber) ?? null;
|
||||||
|
@ -189,7 +212,7 @@ async function onDrop(value: string, event: MouseEvent) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.sqlEditor">
|
<div ref="container" :class="$style.sqlEditor" @keydown.tab="onBlur">
|
||||||
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
|
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
|
||||||
<template #default="{ activeDrop, droppable }">
|
<template #default="{ activeDrop, droppable }">
|
||||||
<div
|
<div
|
||||||
|
@ -207,7 +230,7 @@ async function onDrop(value: string, event: MouseEvent) {
|
||||||
v-if="!fullscreen"
|
v-if="!fullscreen"
|
||||||
:segments="segments"
|
:segments="segments"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:visible="hasFocus"
|
:visible="isFocused"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -62,6 +62,7 @@ const isTouchActive = ref<boolean>(false);
|
||||||
const forceActions = ref(false);
|
const forceActions = ref(false);
|
||||||
const isColorPopoverVisible = ref(false);
|
const isColorPopoverVisible = ref(false);
|
||||||
const stickOptions = ref<HTMLElement>();
|
const stickOptions = ref<HTMLElement>();
|
||||||
|
const isEditing = ref(false);
|
||||||
|
|
||||||
const setForceActions = (value: boolean) => {
|
const setForceActions = (value: boolean) => {
|
||||||
forceActions.value = value;
|
forceActions.value = value;
|
||||||
|
@ -147,8 +148,13 @@ const workflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
|
||||||
|
|
||||||
const showActions = computed(
|
const showActions = computed(
|
||||||
() =>
|
() =>
|
||||||
!(props.hideActions || props.isReadOnly || workflowRunning.value || isResizing.value) ||
|
!(
|
||||||
forceActions.value,
|
props.hideActions ||
|
||||||
|
isEditing.value ||
|
||||||
|
props.isReadOnly ||
|
||||||
|
workflowRunning.value ||
|
||||||
|
isResizing.value
|
||||||
|
) || forceActions.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
@ -187,6 +193,7 @@ const changeColor = (index: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEdit = (edit: boolean) => {
|
const onEdit = (edit: boolean) => {
|
||||||
|
isEditing.value = edit;
|
||||||
if (edit && !props.isActive && node.value) {
|
if (edit && !props.isActive && node.value) {
|
||||||
ndvStore.activeNodeName = node.value.name;
|
ndvStore.activeNodeName = node.value.name;
|
||||||
} else if (props.isActive && !edit) {
|
} else if (props.isActive && !edit) {
|
||||||
|
|
|
@ -0,0 +1,281 @@
|
||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`InputPanel > should render 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="run-data container"
|
||||||
|
data-test-id="ndv-input-panel"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="n8n-callout callout secondary round pinnedDataCallout"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="messageSection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="icon"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="n8n-text compact size-medium regular n8n-icon n8n-icon"
|
||||||
|
>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-thumbtack fa-w-12 medium"
|
||||||
|
data-icon="thumbtack"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 384 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
class=""
|
||||||
|
d="M298.028 214.267L285.793 96H328c13.255 0 24-10.745 24-24V24c0-13.255-10.745-24-24-24H56C42.745 0 32 10.745 32 24v48c0 13.255 10.745 24 24 24h42.207L85.972 214.267C37.465 236.82 0 277.261 0 328c0 13.255 10.745 24 24 24h136v104.007c0 1.242.289 2.467.845 3.578l24 48c2.941 5.882 11.364 5.893 14.311 0l24-48a8.008 8.008 0 0 0 .845-3.578V352h136c13.255 0 24-10.745 24-24-.001-51.183-37.983-91.42-85.973-113.733z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="n8n-text size-small regular"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
This data is pinned.
|
||||||
|
<span
|
||||||
|
class="ml-4xs"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="n8n-link"
|
||||||
|
data-test-id="ndv-unpin-data"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="secondary-underline"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="n8n-text size-small bold"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
Unpin
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="n8n-link"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
href="https://docs.n8n.io/data/data-pinning/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="secondary-underline"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="n8n-text size-small bold"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
Learn more
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
<div
|
||||||
|
class="header"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="titleSection"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Input
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="n8n-radio-buttons radioGroup"
|
||||||
|
data-test-id="input-panel-mode"
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
aria-checked="false"
|
||||||
|
class="n8n-radio-button container hoverable"
|
||||||
|
role="radio"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="button medium"
|
||||||
|
data-test-id="radio-button-mapping"
|
||||||
|
>
|
||||||
|
Mapping
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
aria-checked="true"
|
||||||
|
class="n8n-radio-button container hoverable"
|
||||||
|
role="radio"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="button active medium"
|
||||||
|
data-test-id="radio-button-debugging"
|
||||||
|
>
|
||||||
|
Debugging
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="displayModes"
|
||||||
|
data-test-id="run-data-pane-header"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
<div
|
||||||
|
class="n8n-radio-buttons radioGroup"
|
||||||
|
data-test-id="ndv-run-data-display-mode"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
aria-checked="true"
|
||||||
|
class="n8n-radio-button container hoverable"
|
||||||
|
role="radio"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="button active medium"
|
||||||
|
data-test-id="radio-button-schema"
|
||||||
|
>
|
||||||
|
Schema
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
aria-checked="false"
|
||||||
|
class="n8n-radio-button container hoverable"
|
||||||
|
role="radio"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="button medium"
|
||||||
|
data-test-id="radio-button-table"
|
||||||
|
>
|
||||||
|
Table
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
aria-checked="false"
|
||||||
|
class="n8n-radio-button container hoverable"
|
||||||
|
role="radio"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="button medium"
|
||||||
|
data-test-id="radio-button-json"
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
<!--v-if-->
|
||||||
|
<div
|
||||||
|
class="editModeActions"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-live="polite"
|
||||||
|
class="button button tertiary medium"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
>
|
||||||
|
<!--v-if-->
|
||||||
|
<span>
|
||||||
|
|
||||||
|
Cancel
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-live="polite"
|
||||||
|
class="button button primary medium ml-2xs ml-2xs"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
>
|
||||||
|
<!--v-if-->
|
||||||
|
<span>
|
||||||
|
|
||||||
|
Save
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
<!--v-if-->
|
||||||
|
<!--v-if-->
|
||||||
|
|
||||||
|
|
||||||
|
<!--v-if-->
|
||||||
|
<div
|
||||||
|
class="dataContainer"
|
||||||
|
data-test-id="ndv-data-container"
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
>
|
||||||
|
<!---->
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
<transition-stub
|
||||||
|
appear="false"
|
||||||
|
class="uiBlocker"
|
||||||
|
css="true"
|
||||||
|
data-v-249d9307=""
|
||||||
|
data-v-2e5cd75c=""
|
||||||
|
mode="out-in"
|
||||||
|
name="fade"
|
||||||
|
persisted="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="n8n-block-ui uiBlocker"
|
||||||
|
data-v-249d9307=""
|
||||||
|
role="dialog"
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
</transition-stub>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -702,13 +702,13 @@ exports[`RunDataSchema.vue > renders schema for data 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
checked=""
|
checked=""
|
||||||
id="set_1-hobbies"
|
id="ndv_set_1-hobbies"
|
||||||
inert=""
|
inert=""
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toggle"
|
class="toggle"
|
||||||
for="set_1-hobbies"
|
for="ndv_set_1-hobbies"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -1138,13 +1138,13 @@ exports[`RunDataSchema.vue > renders schema for data 2`] = `
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
checked=""
|
checked=""
|
||||||
id="set_2-hobbies"
|
id="ndv_set_2-hobbies"
|
||||||
inert=""
|
inert=""
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toggle"
|
class="toggle"
|
||||||
for="set_2-hobbies"
|
for="ndv_set_2-hobbies"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -1575,13 +1575,13 @@ exports[`RunDataSchema.vue > renders schema in output pane 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
checked=""
|
checked=""
|
||||||
id="output_object-0-0-hobbies"
|
id="ndv_output_object-0-0-hobbies"
|
||||||
inert=""
|
inert=""
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toggle"
|
class="toggle"
|
||||||
for="output_object-0-0-hobbies"
|
for="ndv_output_object-0-0-hobbies"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -1967,13 +1967,13 @@ exports[`RunDataSchema.vue > renders schema with spaces and dots 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
checked=""
|
checked=""
|
||||||
id="set_1-hello world"
|
id="ndv_set_1-hello world"
|
||||||
inert=""
|
inert=""
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toggle"
|
class="toggle"
|
||||||
for="set_1-hello world"
|
for="ndv_set_1-hello world"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -2060,13 +2060,13 @@ exports[`RunDataSchema.vue > renders schema with spaces and dots 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
checked=""
|
checked=""
|
||||||
id="set_1-hello world-0"
|
id="ndv_set_1-hello world-0"
|
||||||
inert=""
|
inert=""
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toggle"
|
class="toggle"
|
||||||
for="set_1-hello world-0"
|
for="ndv_set_1-hello world-0"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -2144,13 +2144,13 @@ exports[`RunDataSchema.vue > renders schema with spaces and dots 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
checked=""
|
checked=""
|
||||||
id="set_1-hello world-0-test"
|
id="ndv_set_1-hello world-0-test"
|
||||||
inert=""
|
inert=""
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="toggle"
|
class="toggle"
|
||||||
for="set_1-hello world-0-test"
|
for="ndv_set_1-hello world-0-test"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { fireEvent } from '@testing-library/vue';
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CanvasNodeStickyNote);
|
const renderComponent = createComponentRenderer(CanvasNodeStickyNote);
|
||||||
|
|
||||||
|
@ -42,4 +43,29 @@ describe('CanvasNodeStickyNote', () => {
|
||||||
|
|
||||||
expect(resizeControls).toHaveLength(0);
|
expect(resizeControls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should disable sticky options when in edit mode', async () => {
|
||||||
|
const { container } = renderComponent({
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
...createCanvasNodeProvide({
|
||||||
|
id: 'sticky',
|
||||||
|
readOnly: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stickyTextarea = container.querySelector('.sticky-textarea');
|
||||||
|
|
||||||
|
if (!stickyTextarea) return;
|
||||||
|
|
||||||
|
await fireEvent.dblClick(stickyTextarea);
|
||||||
|
|
||||||
|
const stickyOptions = container.querySelector('.sticky-options');
|
||||||
|
|
||||||
|
if (!stickyOptions) return;
|
||||||
|
|
||||||
|
expect(getComputedStyle(stickyOptions).display).toBe('none');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,7 @@ const {
|
||||||
hasIssues,
|
hasIssues,
|
||||||
executionStatus,
|
executionStatus,
|
||||||
executionWaiting,
|
executionWaiting,
|
||||||
executionRunning,
|
executionRunningThrottled,
|
||||||
hasRunData,
|
hasRunData,
|
||||||
runDataIterations,
|
runDataIterations,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
@ -58,7 +58,7 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
|
||||||
<!-- Do nothing, unknown means the node never executed -->
|
<!-- Do nothing, unknown means the node never executed -->
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="executionRunning || executionStatus === 'running'"
|
v-else-if="executionRunningThrottled || executionStatus === 'running'"
|
||||||
data-test-id="canvas-node-status-running"
|
data-test-id="canvas-node-status-running"
|
||||||
:class="[$style.status, $style.running]"
|
:class="[$style.status, $style.running]"
|
||||||
>
|
>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { CanvasNodeKey } from '@/constants';
|
||||||
import { computed, inject } from 'vue';
|
import { computed, inject } from 'vue';
|
||||||
import type { CanvasNodeData } from '@/types';
|
import type { CanvasNodeData } from '@/types';
|
||||||
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
|
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
|
||||||
|
import { refThrottled } from '@vueuse/core';
|
||||||
|
|
||||||
export function useCanvasNode() {
|
export function useCanvasNode() {
|
||||||
const node = inject(CanvasNodeKey);
|
const node = inject(CanvasNodeKey);
|
||||||
|
@ -58,6 +59,7 @@ export function useCanvasNode() {
|
||||||
const executionStatus = computed(() => data.value.execution.status);
|
const executionStatus = computed(() => data.value.execution.status);
|
||||||
const executionWaiting = computed(() => data.value.execution.waiting);
|
const executionWaiting = computed(() => data.value.execution.waiting);
|
||||||
const executionRunning = computed(() => data.value.execution.running);
|
const executionRunning = computed(() => data.value.execution.running);
|
||||||
|
const executionRunningThrottled = refThrottled(executionRunning, 300);
|
||||||
|
|
||||||
const runDataOutputMap = computed(() => data.value.runData.outputMap);
|
const runDataOutputMap = computed(() => data.value.runData.outputMap);
|
||||||
const runDataIterations = computed(() => data.value.runData.iterations);
|
const runDataIterations = computed(() => data.value.runData.iterations);
|
||||||
|
@ -89,6 +91,7 @@ export function useCanvasNode() {
|
||||||
executionStatus,
|
executionStatus,
|
||||||
executionWaiting,
|
executionWaiting,
|
||||||
executionRunning,
|
executionRunning,
|
||||||
|
executionRunningThrottled,
|
||||||
render,
|
render,
|
||||||
eventBus,
|
eventBus,
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { useHistoryStore } from '@/stores/history.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import {
|
import {
|
||||||
createTestNode,
|
createTestNode,
|
||||||
|
createTestWorkflow,
|
||||||
createTestWorkflowObject,
|
createTestWorkflowObject,
|
||||||
mockNode,
|
mockNode,
|
||||||
mockNodeTypeDescription,
|
mockNodeTypeDescription,
|
||||||
|
@ -2033,6 +2034,22 @@ describe('useCanvasOperations', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('initializeWorkspace', () => {
|
||||||
|
it('should initialize the workspace', () => {
|
||||||
|
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
const workflow = createTestWorkflow({
|
||||||
|
nodes: [createTestNode()],
|
||||||
|
connections: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { initializeWorkspace } = useCanvasOperations({ router });
|
||||||
|
initializeWorkspace(workflow);
|
||||||
|
|
||||||
|
expect(workflowsStore.setNodes).toHaveBeenCalled();
|
||||||
|
expect(workflowsStore.setConnections).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildImportNodes() {
|
function buildImportNodes() {
|
||||||
|
|
|
@ -613,12 +613,11 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
}
|
}
|
||||||
|
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
workflowsStore.setNodePristine(nodeData.name, true);
|
|
||||||
|
|
||||||
if (!options.keepPristine) {
|
if (!options.keepPristine) {
|
||||||
uiStore.stateIsDirty = true;
|
uiStore.stateIsDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workflowsStore.setNodePristine(nodeData.name, true);
|
||||||
nodeHelpers.matchCredentials(nodeData);
|
nodeHelpers.matchCredentials(nodeData);
|
||||||
nodeHelpers.updateNodeParameterIssues(nodeData);
|
nodeHelpers.updateNodeParameterIssues(nodeData);
|
||||||
nodeHelpers.updateNodeCredentialIssues(nodeData);
|
nodeHelpers.updateNodeCredentialIssues(nodeData);
|
||||||
|
@ -1381,15 +1380,15 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
nodeHelpers.credentialsUpdated.value = false;
|
nodeHelpers.credentialsUpdated.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initializeWorkspace(data: IWorkflowDb) {
|
function initializeWorkspace(data: IWorkflowDb) {
|
||||||
// Set workflow data
|
|
||||||
workflowHelpers.initState(data);
|
workflowHelpers.initState(data);
|
||||||
|
|
||||||
// Add nodes and connections
|
data.nodes.forEach((node) => {
|
||||||
await addNodes(data.nodes, { keepPristine: true });
|
nodeHelpers.matchCredentials(node);
|
||||||
await addConnections(mapLegacyConnectionsToCanvasConnections(data.connections, data.nodes), {
|
|
||||||
keepPristine: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
workflowsStore.setNodes(data.nodes);
|
||||||
|
workflowsStore.setConnections(data.connections);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -242,6 +242,9 @@ export const hoverTooltipSource = (view: EditorView, pos: number) => {
|
||||||
const state = view.state.field(cursorInfoBoxTooltip, false);
|
const state = view.state.field(cursorInfoBoxTooltip, false);
|
||||||
const cursorTooltipOpen = !!state?.tooltip;
|
const cursorTooltipOpen = !!state?.tooltip;
|
||||||
|
|
||||||
|
// Don't show hover tooltips when autocomplete is active
|
||||||
|
if (completionStatus(view.state) === 'active') return null;
|
||||||
|
|
||||||
const jsNodeResult = getJsNodeAtPosition(view.state, pos);
|
const jsNodeResult = getJsNodeAtPosition(view.state, pos);
|
||||||
|
|
||||||
if (!jsNodeResult) {
|
if (!jsNodeResult) {
|
||||||
|
|
|
@ -6,6 +6,16 @@ import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { hoverTooltipSource, infoBoxTooltips } from './InfoBoxTooltip';
|
import { hoverTooltipSource, infoBoxTooltips } from './InfoBoxTooltip';
|
||||||
import * as utils from '@/plugins/codemirror/completions/utils';
|
import * as utils from '@/plugins/codemirror/completions/utils';
|
||||||
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
||||||
|
import { completionStatus } from '@codemirror/autocomplete';
|
||||||
|
|
||||||
|
vi.mock('@codemirror/autocomplete', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<{}>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
completionStatus: vi.fn(() => null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('Infobox tooltips', () => {
|
describe('Infobox tooltips', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -99,6 +109,13 @@ describe('Infobox tooltips', () => {
|
||||||
expect(tooltip).not.toBeNull();
|
expect(tooltip).not.toBeNull();
|
||||||
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('includes(searchString, start?)');
|
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('includes(searchString, start?)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should not show a tooltip when autocomplete is open', () => {
|
||||||
|
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('foo');
|
||||||
|
vi.mocked(completionStatus).mockReturnValue('active');
|
||||||
|
const tooltip = hoverTooltip('{{ $json.str.includ|es() }}');
|
||||||
|
expect(tooltip).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1046,6 +1046,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
|
|
||||||
function setNodes(nodes: INodeUi[]): void {
|
function setNodes(nodes: INodeUi[]): void {
|
||||||
workflow.value.nodes = nodes;
|
workflow.value.nodes = nodes;
|
||||||
|
nodeMetadata.value = nodes.reduce<NodeMetadataMap>((acc, node) => {
|
||||||
|
acc[node.name] = { pristine: true };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConnections(connections: IConnections): void {
|
function setConnections(connections: IConnections): void {
|
||||||
|
|
|
@ -354,7 +354,7 @@ async function initializeWorkspaceForExistingWorkflow(id: string) {
|
||||||
try {
|
try {
|
||||||
const workflowData = await workflowsStore.fetchWorkflow(id);
|
const workflowData = await workflowsStore.fetchWorkflow(id);
|
||||||
|
|
||||||
await openWorkflow(workflowData);
|
openWorkflow(workflowData);
|
||||||
|
|
||||||
if (workflowData.meta?.onboardingId) {
|
if (workflowData.meta?.onboardingId) {
|
||||||
trackOpenWorkflowFromOnboardingTemplate();
|
trackOpenWorkflowFromOnboardingTemplate();
|
||||||
|
@ -379,11 +379,11 @@ async function initializeWorkspaceForExistingWorkflow(id: string) {
|
||||||
* Workflow
|
* Workflow
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function openWorkflow(data: IWorkflowDb) {
|
function openWorkflow(data: IWorkflowDb) {
|
||||||
resetWorkspace();
|
resetWorkspace();
|
||||||
workflowHelpers.setDocumentTitle(data.name, 'IDLE');
|
workflowHelpers.setDocumentTitle(data.name, 'IDLE');
|
||||||
|
|
||||||
await initializeWorkspace(data);
|
initializeWorkspace(data);
|
||||||
|
|
||||||
void externalHooks.run('workflow.open', {
|
void externalHooks.run('workflow.open', {
|
||||||
workflowId: data.id,
|
workflowId: data.id,
|
||||||
|
@ -815,7 +815,8 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork
|
||||||
resetWorkspace();
|
resetWorkspace();
|
||||||
|
|
||||||
await initializeData();
|
await initializeData();
|
||||||
await initializeWorkspace({
|
|
||||||
|
initializeWorkspace({
|
||||||
...workflowData,
|
...workflowData,
|
||||||
nodes: NodeViewUtils.getFixedNodesList<INodeUi>(workflowData.nodes),
|
nodes: NodeViewUtils.getFixedNodesList<INodeUi>(workflowData.nodes),
|
||||||
} as IWorkflowDb);
|
} as IWorkflowDb);
|
||||||
|
@ -1074,7 +1075,9 @@ async function openExecution(executionId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await initializeData();
|
await initializeData();
|
||||||
await initializeWorkspace(data.workflowData);
|
|
||||||
|
initializeWorkspace(data.workflowData);
|
||||||
|
|
||||||
workflowsStore.setWorkflowExecutionData(data);
|
workflowsStore.setWorkflowExecutionData(data);
|
||||||
|
|
||||||
uiStore.stateIsDirty = false;
|
uiStore.stateIsDirty = false;
|
||||||
|
@ -1254,7 +1257,7 @@ async function onSourceControlPull() {
|
||||||
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
|
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
|
||||||
if (workflowData) {
|
if (workflowData) {
|
||||||
workflowHelpers.setDocumentTitle(workflowData.name, 'IDLE');
|
workflowHelpers.setDocumentTitle(workflowData.name, 'IDLE');
|
||||||
await openWorkflow(workflowData);
|
openWorkflow(workflowData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
INode,
|
INode,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
|
INodeParameterResourceLocator,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
IPairedItemData,
|
IPairedItemData,
|
||||||
IPollFunctions,
|
IPollFunctions,
|
||||||
|
@ -23,7 +24,7 @@ import moment from 'moment-timezone';
|
||||||
import { validate as uuidValidate } from 'uuid';
|
import { validate as uuidValidate } from 'uuid';
|
||||||
import set from 'lodash/set';
|
import set from 'lodash/set';
|
||||||
import { filters } from './descriptions/Filters';
|
import { filters } from './descriptions/Filters';
|
||||||
import { blockUrlExtractionRegexp } from './constants';
|
import { blockUrlExtractionRegexp, databasePageUrlValidationRegexp } from './constants';
|
||||||
|
|
||||||
function uuidValidateWithoutDashes(this: IExecuteFunctions, value: string) {
|
function uuidValidateWithoutDashes(this: IExecuteFunctions, value: string) {
|
||||||
if (uuidValidate(value)) return true;
|
if (uuidValidate(value)) return true;
|
||||||
|
@ -916,6 +917,32 @@ export function extractPageId(page = '') {
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPageId(this: IExecuteFunctions, i: number) {
|
||||||
|
const page = this.getNodeParameter('pageId', i, {}) as INodeParameterResourceLocator;
|
||||||
|
let pageId = '';
|
||||||
|
|
||||||
|
if (page.value && typeof page.value === 'string') {
|
||||||
|
if (page.mode === 'id') {
|
||||||
|
pageId = page.value;
|
||||||
|
} else if (page.value.includes('p=')) {
|
||||||
|
// e.g https://www.notion.so/xxxxx?v=xxxxx&p=xxxxx&pm=s
|
||||||
|
pageId = new URLSearchParams(page.value).get('p') || '';
|
||||||
|
} else {
|
||||||
|
// e.g https://www.notion.so/page_name-xxxxx
|
||||||
|
pageId = page.value.match(databasePageUrlValidationRegexp)?.[1] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageId) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.getNode(),
|
||||||
|
'Could not extract page ID from URL: ' + page.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageId;
|
||||||
|
}
|
||||||
|
|
||||||
export function extractDatabaseId(database: string) {
|
export function extractDatabaseId(database: string) {
|
||||||
if (database.includes('?v=')) {
|
if (database.includes('?v=')) {
|
||||||
const data = database.split('?v=')[0].split('/');
|
const data = database.split('?v=')[0].split('/');
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
import type { IExecuteFunctions, INode, INodeParameterResourceLocator } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
import { databasePageUrlExtractionRegexp } from '../shared/constants';
|
import { databasePageUrlExtractionRegexp } from '../shared/constants';
|
||||||
import { extractPageId, formatBlocks } from '../shared/GenericFunctions';
|
import { extractPageId, formatBlocks, getPageId } from '../shared/GenericFunctions';
|
||||||
|
import type { MockProxy } from 'jest-mock-extended';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
describe('Test NotionV2, formatBlocks', () => {
|
describe('Test NotionV2, formatBlocks', () => {
|
||||||
it('should format to_do block', () => {
|
it('should format to_do block', () => {
|
||||||
|
@ -89,3 +93,113 @@ describe('Test Notion', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Test Notion, getPageId', () => {
|
||||||
|
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||||
|
const id = '3ab5bc794647496dac48feca926813fd';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return page ID directly when mode is id', () => {
|
||||||
|
const page = {
|
||||||
|
mode: 'id',
|
||||||
|
value: id,
|
||||||
|
} as INodeParameterResourceLocator;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getNodeParameter.mockReturnValue(page);
|
||||||
|
|
||||||
|
const result = getPageId.call(mockExecuteFunctions, 0);
|
||||||
|
expect(result).toBe(id);
|
||||||
|
expect(mockExecuteFunctions.getNodeParameter).toHaveBeenCalledWith('pageId', 0, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract page ID from URL with p parameter', () => {
|
||||||
|
const page = {
|
||||||
|
mode: 'url',
|
||||||
|
value: `https://www.notion.so/xxxxx?v=xxxxx&p=${id}&pm=s`,
|
||||||
|
} as INodeParameterResourceLocator;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getNodeParameter.mockReturnValue(page);
|
||||||
|
|
||||||
|
const result = getPageId.call(mockExecuteFunctions, 0);
|
||||||
|
expect(result).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract page ID from URL using regex', () => {
|
||||||
|
const page = {
|
||||||
|
mode: 'url',
|
||||||
|
value: `https://www.notion.so/page-name-${id}`,
|
||||||
|
} as INodeParameterResourceLocator;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getNodeParameter.mockReturnValue(page);
|
||||||
|
|
||||||
|
const result = getPageId.call(mockExecuteFunctions, 0);
|
||||||
|
expect(result).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when page ID cannot be extracted', () => {
|
||||||
|
const page = {
|
||||||
|
mode: 'url',
|
||||||
|
value: 'https://www.notion.so/invalid-url',
|
||||||
|
} as INodeParameterResourceLocator;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getNodeParameter.mockReturnValue(page);
|
||||||
|
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ name: 'Notion', type: 'notion' }));
|
||||||
|
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(NodeOperationError);
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(
|
||||||
|
'Could not extract page ID from URL: https://www.notion.so/invalid-url',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when page value is empty', () => {
|
||||||
|
const page = {
|
||||||
|
mode: 'url',
|
||||||
|
value: '',
|
||||||
|
} as INodeParameterResourceLocator;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getNodeParameter.mockReturnValue(page);
|
||||||
|
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ name: 'Notion', type: 'notion' }));
|
||||||
|
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(NodeOperationError);
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(
|
||||||
|
'Could not extract page ID from URL: ',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when page value is undefined', () => {
|
||||||
|
const page = {
|
||||||
|
mode: 'url',
|
||||||
|
value: undefined,
|
||||||
|
} as INodeParameterResourceLocator;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getNodeParameter.mockReturnValue(page);
|
||||||
|
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ name: 'Notion', type: 'notion' }));
|
||||||
|
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(NodeOperationError);
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(
|
||||||
|
'Could not extract page ID from URL: undefined',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when page value is not a string', () => {
|
||||||
|
const page = {
|
||||||
|
mode: 'url',
|
||||||
|
value: 123 as any,
|
||||||
|
} as INodeParameterResourceLocator;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getNodeParameter.mockReturnValue(page);
|
||||||
|
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ name: 'Notion', type: 'notion' }));
|
||||||
|
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(NodeOperationError);
|
||||||
|
expect(() => getPageId.call(mockExecuteFunctions, 0)).toThrow(
|
||||||
|
'Could not extract page ID from URL: 123',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {
|
||||||
extractBlockId,
|
extractBlockId,
|
||||||
extractDatabaseId,
|
extractDatabaseId,
|
||||||
extractDatabaseMentionRLC,
|
extractDatabaseMentionRLC,
|
||||||
extractPageId,
|
getPageId,
|
||||||
formatBlocks,
|
formatBlocks,
|
||||||
formatTitle,
|
formatTitle,
|
||||||
mapFilters,
|
mapFilters,
|
||||||
|
@ -401,9 +401,8 @@ export class NotionV2 implements INodeType {
|
||||||
if (operation === 'get') {
|
if (operation === 'get') {
|
||||||
for (let i = 0; i < itemsLength; i++) {
|
for (let i = 0; i < itemsLength; i++) {
|
||||||
try {
|
try {
|
||||||
const pageId = extractPageId(
|
const pageId = getPageId.call(this, i);
|
||||||
this.getNodeParameter('pageId', i, '', { extractValue: true }) as string,
|
|
||||||
);
|
|
||||||
const simple = this.getNodeParameter('simple', i) as boolean;
|
const simple = this.getNodeParameter('simple', i) as boolean;
|
||||||
responseData = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`);
|
responseData = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`);
|
||||||
if (simple) {
|
if (simple) {
|
||||||
|
@ -526,9 +525,7 @@ export class NotionV2 implements INodeType {
|
||||||
if (operation === 'update') {
|
if (operation === 'update') {
|
||||||
for (let i = 0; i < itemsLength; i++) {
|
for (let i = 0; i < itemsLength; i++) {
|
||||||
try {
|
try {
|
||||||
const pageId = extractPageId(
|
const pageId = getPageId.call(this, i);
|
||||||
this.getNodeParameter('pageId', i, '', { extractValue: true }) as string,
|
|
||||||
);
|
|
||||||
const simple = this.getNodeParameter('simple', i) as boolean;
|
const simple = this.getNodeParameter('simple', i) as boolean;
|
||||||
const properties = this.getNodeParameter(
|
const properties = this.getNodeParameter(
|
||||||
'propertiesUi.propertyValues',
|
'propertiesUi.propertyValues',
|
||||||
|
@ -635,9 +632,7 @@ export class NotionV2 implements INodeType {
|
||||||
if (operation === 'archive') {
|
if (operation === 'archive') {
|
||||||
for (let i = 0; i < itemsLength; i++) {
|
for (let i = 0; i < itemsLength; i++) {
|
||||||
try {
|
try {
|
||||||
const pageId = extractPageId(
|
const pageId = getPageId.call(this, i);
|
||||||
this.getNodeParameter('pageId', i, '', { extractValue: true }) as string,
|
|
||||||
);
|
|
||||||
const simple = this.getNodeParameter('simple', i) as boolean;
|
const simple = this.getNodeParameter('simple', i) as boolean;
|
||||||
responseData = await notionApiRequest.call(this, 'PATCH', `/pages/${pageId}`, {
|
responseData = await notionApiRequest.call(this, 'PATCH', `/pages/${pageId}`, {
|
||||||
archived: true,
|
archived: true,
|
||||||
|
@ -672,9 +667,7 @@ export class NotionV2 implements INodeType {
|
||||||
parent: {},
|
parent: {},
|
||||||
properties: {},
|
properties: {},
|
||||||
};
|
};
|
||||||
body.parent.page_id = extractPageId(
|
body.parent.page_id = getPageId.call(this, i);
|
||||||
this.getNodeParameter('pageId', i, '', { extractValue: true }) as string,
|
|
||||||
);
|
|
||||||
body.properties = formatTitle(this.getNodeParameter('title', i) as string);
|
body.properties = formatTitle(this.getNodeParameter('title', i) as string);
|
||||||
const blockValues = this.getNodeParameter(
|
const blockValues = this.getNodeParameter(
|
||||||
'blockUi.blockValues',
|
'blockUi.blockValues',
|
||||||
|
|
|
@ -2080,6 +2080,9 @@ export type INodeTypeData = LoadedData<INodeType | IVersionedNodeType>;
|
||||||
|
|
||||||
export interface IRun {
|
export interface IRun {
|
||||||
data: IRunExecutionData;
|
data: IRunExecutionData;
|
||||||
|
/**
|
||||||
|
* @deprecated Use status instead
|
||||||
|
*/
|
||||||
finished?: boolean;
|
finished?: boolean;
|
||||||
mode: WorkflowExecuteMode;
|
mode: WorkflowExecuteMode;
|
||||||
waitTill?: Date | null;
|
waitTill?: Date | null;
|
||||||
|
@ -2114,6 +2117,7 @@ export interface IRunExecutionData {
|
||||||
waitingExecutionSource: IWaitingForExecutionSource | null;
|
waitingExecutionSource: IWaitingForExecutionSource | null;
|
||||||
};
|
};
|
||||||
waitTill?: Date;
|
waitTill?: Date;
|
||||||
|
pushRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRunData {
|
export interface IRunData {
|
||||||
|
@ -2551,6 +2555,9 @@ export type AnnotationVote = 'up' | 'down';
|
||||||
|
|
||||||
export interface ExecutionSummary {
|
export interface ExecutionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* @deprecated Use status instead
|
||||||
|
*/
|
||||||
finished?: boolean;
|
finished?: boolean;
|
||||||
mode: WorkflowExecuteMode;
|
mode: WorkflowExecuteMode;
|
||||||
retryOf?: string | null;
|
retryOf?: string | null;
|
||||||
|
@ -2700,6 +2707,9 @@ export interface ExecutionOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutionFilters {
|
export interface ExecutionFilters {
|
||||||
|
/**
|
||||||
|
* @deprecated Use status instead
|
||||||
|
*/
|
||||||
finished?: boolean;
|
finished?: boolean;
|
||||||
mode?: WorkflowExecuteMode[];
|
mode?: WorkflowExecuteMode[];
|
||||||
retryOf?: string;
|
retryOf?: string;
|
||||||
|
|
Loading…
Reference in a new issue