mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Add sorting to GET /workflows
endpoint (#13029)
This commit is contained in:
parent
18eaa5423d
commit
b60011a180
|
@ -158,6 +158,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||||
findManyOptions.order = { updatedAt: 'ASC' };
|
findManyOptions.order = { updatedAt: 'ASC' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.sortBy) {
|
||||||
|
const [column, order] = options.sortBy.split(':');
|
||||||
|
findManyOptions.order = { [column]: order };
|
||||||
|
}
|
||||||
|
|
||||||
if (relations.length > 0) {
|
if (relations.length > 0) {
|
||||||
findManyOptions.relations = relations;
|
findManyOptions.relations = relations;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { selectListQueryMiddleware } from '@/middlewares/list-query/select';
|
||||||
import type { ListQuery } from '@/requests';
|
import type { ListQuery } from '@/requests';
|
||||||
import * as ResponseHelper from '@/response-helper';
|
import * as ResponseHelper from '@/response-helper';
|
||||||
|
|
||||||
|
import { sortByQueryMiddleware } from '../sort-by';
|
||||||
|
|
||||||
describe('List query middleware', () => {
|
describe('List query middleware', () => {
|
||||||
let mockReq: ListQuery.Request;
|
let mockReq: ListQuery.Request;
|
||||||
let mockRes: Response;
|
let mockRes: Response;
|
||||||
|
@ -174,6 +176,84 @@ describe('List query middleware', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Query sort by', () => {
|
||||||
|
const validCases: Array<{ name: string; value: ListQuery.Workflow.SortOrder }> = [
|
||||||
|
{
|
||||||
|
name: 'sorting by name asc',
|
||||||
|
value: 'name:asc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sorting by name desc',
|
||||||
|
value: 'name:desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sorting by createdAt asc',
|
||||||
|
value: 'createdAt:asc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sorting by createdAt desc',
|
||||||
|
value: 'createdAt:desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sorting by updatedAt asc',
|
||||||
|
value: 'updatedAt:asc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sorting by updatedAt desc',
|
||||||
|
value: 'updatedAt:desc',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const invalidCases: Array<{ name: string; value: string }> = [
|
||||||
|
{
|
||||||
|
name: 'sorting by invalid column',
|
||||||
|
value: 'test:asc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sorting by valid column without order',
|
||||||
|
value: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sorting by valid column with invalid order',
|
||||||
|
value: 'name:test',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(validCases)('should succeed validation when $name', async ({ value }) => {
|
||||||
|
mockReq.query = {
|
||||||
|
sortBy: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
sortByQueryMiddleware(...args);
|
||||||
|
|
||||||
|
expect(mockReq.listQueryOptions).toMatchObject(
|
||||||
|
expect.objectContaining({
|
||||||
|
sortBy: value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(nextFn).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(invalidCases)('should fail validation when $name', async ({ value }) => {
|
||||||
|
mockReq.query = {
|
||||||
|
sortBy: value as ListQuery.Workflow.SortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
sortByQueryMiddleware(...args);
|
||||||
|
|
||||||
|
expect(sendErrorResponse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not pass sortBy to listQueryOptions if not provided', async () => {
|
||||||
|
mockReq.query = {};
|
||||||
|
|
||||||
|
sortByQueryMiddleware(...args);
|
||||||
|
|
||||||
|
expect(mockReq.listQueryOptions).toBeUndefined();
|
||||||
|
expect(nextFn).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Combinations', () => {
|
describe('Combinations', () => {
|
||||||
test('should combine filter with select', async () => {
|
test('should combine filter with select', async () => {
|
||||||
mockReq.query = { filter: '{ "name": "My Workflow" }', select: '["name", "id"]' };
|
mockReq.query = { filter: '{ "name": "My Workflow" }', select: '["name", "id"]' };
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
|
||||||
|
import { IsString, Validate, ValidatorConstraint } from 'class-validator';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'WorkflowSortByParameter', async: false })
|
||||||
|
export class WorkflowSortByParameter implements ValidatorConstraintInterface {
|
||||||
|
validate(text: string, _: ValidationArguments) {
|
||||||
|
const [column, order] = text.split(':');
|
||||||
|
if (!column || !order) return false;
|
||||||
|
|
||||||
|
return ['name', 'createdAt', 'updatedAt'].includes(column) && ['asc', 'desc'].includes(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(_: ValidationArguments) {
|
||||||
|
return 'Invalid value for sortBy parameter';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkflowSorting {
|
||||||
|
@IsString()
|
||||||
|
@Validate(WorkflowSortByParameter)
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
import type { NextFunction, Response } from 'express';
|
import { type NextFunction, type Response } from 'express';
|
||||||
|
|
||||||
import type { ListQuery } from '@/requests';
|
import type { ListQuery } from '@/requests';
|
||||||
|
|
||||||
import { filterListQueryMiddleware } from './filter';
|
import { filterListQueryMiddleware } from './filter';
|
||||||
import { paginationListQueryMiddleware } from './pagination';
|
import { paginationListQueryMiddleware } from './pagination';
|
||||||
import { selectListQueryMiddleware } from './select';
|
import { selectListQueryMiddleware } from './select';
|
||||||
|
import { sortByQueryMiddleware } from './sort-by';
|
||||||
|
|
||||||
export type ListQueryMiddleware = (
|
export type ListQueryMiddleware = (
|
||||||
req: ListQuery.Request,
|
req: ListQuery.Request,
|
||||||
|
@ -16,4 +17,5 @@ export const listQueryMiddleware: ListQueryMiddleware[] = [
|
||||||
filterListQueryMiddleware,
|
filterListQueryMiddleware,
|
||||||
selectListQueryMiddleware,
|
selectListQueryMiddleware,
|
||||||
paginationListQueryMiddleware,
|
paginationListQueryMiddleware,
|
||||||
|
sortByQueryMiddleware,
|
||||||
];
|
];
|
||||||
|
|
39
packages/cli/src/middlewares/list-query/sort-by.ts
Normal file
39
packages/cli/src/middlewares/list-query/sort-by.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
import type { RequestHandler } from 'express';
|
||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { ListQuery } from '@/requests';
|
||||||
|
import * as ResponseHelper from '@/response-helper';
|
||||||
|
import { toError } from '@/utils';
|
||||||
|
|
||||||
|
import { WorkflowSorting } from './dtos/workflow.sort-by.dto';
|
||||||
|
|
||||||
|
export const sortByQueryMiddleware: RequestHandler = (req: ListQuery.Request, res, next) => {
|
||||||
|
const { sortBy } = req.query;
|
||||||
|
|
||||||
|
if (!sortBy) return next();
|
||||||
|
|
||||||
|
let SortBy;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.baseUrl.endsWith('workflows')) {
|
||||||
|
SortBy = WorkflowSorting;
|
||||||
|
} else {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationResponse = validateSync(plainToInstance(SortBy, { sortBy }));
|
||||||
|
|
||||||
|
if (validationResponse.length) {
|
||||||
|
const validationError = validationResponse[0];
|
||||||
|
throw new ApplicationError(validationError.constraints?.workflowSortBy ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
req.listQueryOptions = { ...req.listQueryOptions, sortBy };
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (maybeError) {
|
||||||
|
ResponseHelper.sendErrorResponse(res, toError(maybeError));
|
||||||
|
}
|
||||||
|
};
|
|
@ -62,6 +62,7 @@ export namespace ListQuery {
|
||||||
skip?: string;
|
skip?: string;
|
||||||
take?: string;
|
take?: string;
|
||||||
select?: string;
|
select?: string;
|
||||||
|
sortBy?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Options = {
|
export type Options = {
|
||||||
|
@ -69,6 +70,7 @@ export namespace ListQuery {
|
||||||
select?: Record<string, true>;
|
select?: Record<string, true>;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
take?: number;
|
take?: number;
|
||||||
|
sortBy?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -82,6 +84,10 @@ export namespace ListQuery {
|
||||||
|
|
||||||
type SharedField = Partial<Pick<WorkflowEntity, 'shared'>>;
|
type SharedField = Partial<Pick<WorkflowEntity, 'shared'>>;
|
||||||
|
|
||||||
|
type SortingField = 'createdAt' | 'updatedAt' | 'name';
|
||||||
|
|
||||||
|
export type SortOrder = `${SortingField}:asc` | `${SortingField}:desc`;
|
||||||
|
|
||||||
type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null };
|
type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null };
|
||||||
|
|
||||||
export type Plain = BaseFields;
|
export type Plain = BaseFields;
|
||||||
|
|
|
@ -854,6 +854,97 @@ describe('GET /workflows', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sortBy', () => {
|
||||||
|
test('should fail when trying to sort by non sortable column', async () => {
|
||||||
|
await authOwnerAgent.get('/workflows').query('sortBy=nonSortableColumn:asc').expect(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort by createdAt column', async () => {
|
||||||
|
await createWorkflow({ name: 'First' }, owner);
|
||||||
|
await createWorkflow({ name: 'Second' }, owner);
|
||||||
|
|
||||||
|
let response = await authOwnerAgent
|
||||||
|
.get('/workflows')
|
||||||
|
.query('sortBy=createdAt:asc')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
count: 2,
|
||||||
|
data: arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'First' }),
|
||||||
|
expect.objectContaining({ name: 'Second' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
response = await authOwnerAgent.get('/workflows').query('sortBy=createdAt:desc').expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
count: 2,
|
||||||
|
data: arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'Second' }),
|
||||||
|
expect.objectContaining({ name: 'First' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort by name column', async () => {
|
||||||
|
await createWorkflow({ name: 'a' }, owner);
|
||||||
|
await createWorkflow({ name: 'b' }, owner);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
response = await authOwnerAgent.get('/workflows').query('sortBy=name:asc').expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
count: 2,
|
||||||
|
data: arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'a' }),
|
||||||
|
expect.objectContaining({ name: 'b' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
count: 2,
|
||||||
|
data: arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'b' }),
|
||||||
|
expect.objectContaining({ name: 'a' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort by updatedAt column', async () => {
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + 10);
|
||||||
|
|
||||||
|
await createWorkflow({ name: 'First', updatedAt: futureDate }, owner);
|
||||||
|
await createWorkflow({ name: 'Second' }, owner);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
response = await authOwnerAgent.get('/workflows').query('sortBy=updatedAt:asc').expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
count: 2,
|
||||||
|
data: arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'Second' }),
|
||||||
|
expect.objectContaining({ name: 'First' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
count: 2,
|
||||||
|
data: arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'First' }),
|
||||||
|
expect.objectContaining({ name: 'Second' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /workflows/:workflowId', () => {
|
describe('PATCH /workflows/:workflowId', () => {
|
||||||
|
|
Loading…
Reference in a new issue