mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
feat(core): Create reviews page and render inbox (#34357)
This commit is contained in:
@@ -295,6 +295,7 @@ export type { EncryptionKeyResponseDto } from './encryption/encryption-key-respo
|
||||
export { CreateWorkflowReviewRequestDto } from './workflow-reviews/create-workflow-review-request.dto';
|
||||
export { GetWorkflowReviewEligibleReviewersQueryDto } from './workflow-reviews/get-eligible-reviewers-query.dto';
|
||||
export { ListWorkflowReviewRequestsQueryDto } from './workflow-reviews/list-workflow-review-requests-query.dto';
|
||||
export * from './workflow-reviews/list-workflow-review-inbox.dto';
|
||||
|
||||
export { UpdateOtelSettingsDto } from './otel/update-otel-settings.dto';
|
||||
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type WorkflowReviewRequestSummary,
|
||||
workflowReviewRequestStateSchema,
|
||||
} from '../../workflow-review-request-summary';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/**
|
||||
* Inbox list item — extends {@link WorkflowReviewRequestSummary} (the
|
||||
* workflow-scoped list payload used by the review-required toggle sync) with the
|
||||
* cross-project fields the inbox needs.
|
||||
*/
|
||||
export interface WorkflowReviewInboxItem extends WorkflowReviewRequestSummary {
|
||||
projectId: string;
|
||||
title: string;
|
||||
workflowName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox query. Defaults to open requests when `state` is omitted.
|
||||
* Deferred (LIGO-594): `projectId`, `reviewer`, and `author` filters.
|
||||
*/
|
||||
export class ListWorkflowReviewInboxQueryDto extends Z.class({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(15),
|
||||
// Opaque base64url keyset cursor (ISO createdAt + id), not a bare UUID.
|
||||
cursor: z.string().min(1).max(256).optional(),
|
||||
state: workflowReviewRequestStateSchema.optional(),
|
||||
}) {}
|
||||
|
||||
export interface ListWorkflowReviewInboxResponse {
|
||||
data: WorkflowReviewInboxItem[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface GetWorkflowReviewInboxSummaryResponse {
|
||||
hasAny: boolean;
|
||||
}
|
||||
+165
@@ -11,8 +11,23 @@ describe('WorkflowReviewRequestRepository', () => {
|
||||
const entityManager = mockEntityManager(WorkflowReviewRequest);
|
||||
const repo = Container.get(WorkflowReviewRequestRepository);
|
||||
|
||||
let queryBuilder: Mocked<SelectQueryBuilder<WorkflowReviewRequest>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
queryBuilder = mock<SelectQueryBuilder<WorkflowReviewRequest>>();
|
||||
queryBuilder.where.mockReturnThis();
|
||||
queryBuilder.andWhere.mockReturnThis();
|
||||
queryBuilder.orderBy.mockReturnThis();
|
||||
queryBuilder.addOrderBy.mockReturnThis();
|
||||
queryBuilder.select.mockReturnThis();
|
||||
queryBuilder.take.mockReturnThis();
|
||||
queryBuilder.limit.mockReturnThis();
|
||||
queryBuilder.getMany.mockResolvedValue([]);
|
||||
queryBuilder.getRawOne.mockResolvedValue(undefined);
|
||||
|
||||
vi.spyOn(repo, 'createQueryBuilder').mockReturnValue(queryBuilder);
|
||||
});
|
||||
|
||||
describe('createRequest', () => {
|
||||
@@ -120,4 +135,154 @@ describe('WorkflowReviewRequestRepository', () => {
|
||||
expect(queryBuilder.take).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findManyForInbox', () => {
|
||||
it('filters by requester only when projectIds is empty', async () => {
|
||||
const rows = [mock<WorkflowReviewRequest>({ id: 'req-1' })];
|
||||
queryBuilder.getMany.mockResolvedValueOnce(rows);
|
||||
|
||||
const result = await repo.findManyForInbox({
|
||||
projectIds: [],
|
||||
requesterId: 'user-1',
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
expect(result).toBe(rows);
|
||||
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith('review.createdById = :requesterId', {
|
||||
requesterId: 'user-1',
|
||||
});
|
||||
expect(queryBuilder.take).toHaveBeenCalledWith(15);
|
||||
});
|
||||
|
||||
it('skips the projectId filter when projectIds is null', async () => {
|
||||
const rows = [mock<WorkflowReviewRequest>({ id: 'req-1' })];
|
||||
queryBuilder.getMany.mockResolvedValueOnce(rows);
|
||||
|
||||
const result = await repo.findManyForInbox({
|
||||
projectIds: null,
|
||||
requesterId: 'user-1',
|
||||
state: 'open',
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
expect(result).toBe(rows);
|
||||
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
|
||||
expect(queryBuilder.where).not.toHaveBeenCalled();
|
||||
expect(queryBuilder.orderBy).toHaveBeenCalledWith('review.createdAt', 'DESC');
|
||||
expect(queryBuilder.addOrderBy).toHaveBeenCalledWith('review.id', 'ASC');
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
|
||||
state: 'open',
|
||||
});
|
||||
expect(queryBuilder.take).toHaveBeenCalledWith(15);
|
||||
});
|
||||
|
||||
it('matches projects OR the requester, filtering by state and ordering newest first', async () => {
|
||||
const rows = [mock<WorkflowReviewRequest>({ id: 'req-1' })];
|
||||
queryBuilder.getMany.mockResolvedValueOnce(rows);
|
||||
|
||||
const result = await repo.findManyForInbox({
|
||||
projectIds: ['proj-1', 'proj-2'],
|
||||
requesterId: 'user-1',
|
||||
state: 'open',
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
expect(result).toBe(rows);
|
||||
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith(
|
||||
'(review.projectId IN (:...projectIds) OR review.createdById = :requesterId)',
|
||||
{ projectIds: ['proj-1', 'proj-2'], requesterId: 'user-1' },
|
||||
);
|
||||
expect(queryBuilder.orderBy).toHaveBeenCalledWith('review.createdAt', 'DESC');
|
||||
expect(queryBuilder.addOrderBy).toHaveBeenCalledWith('review.id', 'ASC');
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
|
||||
state: 'open',
|
||||
});
|
||||
expect(queryBuilder.take).toHaveBeenCalledWith(15);
|
||||
});
|
||||
|
||||
it('applies the keyset boundary carried in the cursor without an anchor lookup', async () => {
|
||||
const findOneSpy = vi.spyOn(repo, 'findOne');
|
||||
queryBuilder.getMany.mockResolvedValueOnce([]);
|
||||
const createdAt = new Date('2024-01-02T00:00:00.000Z');
|
||||
|
||||
await repo.findManyForInbox({
|
||||
projectIds: ['proj-1'],
|
||||
requesterId: 'user-1',
|
||||
limit: 10,
|
||||
cursor: { createdAt, id: 'req-cursor' },
|
||||
});
|
||||
|
||||
expect(findOneSpy).not.toHaveBeenCalled();
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'(review.createdAt < :createdAt OR (review.createdAt = :createdAt AND review.id > :id))',
|
||||
{ createdAt, id: 'req-cursor' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existsAnyForInbox', () => {
|
||||
it('queries by requester only when projectIds is empty', async () => {
|
||||
queryBuilder.getRawOne.mockResolvedValueOnce({ '?column?': 1 });
|
||||
|
||||
const result = await repo.existsAnyForInbox({ projectIds: [], requesterId: 'user-1' });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
|
||||
expect(queryBuilder.select).toHaveBeenCalledWith('1');
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith('review.createdById = :requesterId', {
|
||||
requesterId: 'user-1',
|
||||
});
|
||||
expect(queryBuilder.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('skips the projectId filter when projectIds is null', async () => {
|
||||
queryBuilder.getRawOne.mockResolvedValueOnce({ '?column?': 1 });
|
||||
|
||||
const result = await repo.existsAnyForInbox({
|
||||
projectIds: null,
|
||||
requesterId: 'user-1',
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
|
||||
expect(queryBuilder.select).toHaveBeenCalledWith('1');
|
||||
expect(queryBuilder.where).not.toHaveBeenCalled();
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
|
||||
state: 'open',
|
||||
});
|
||||
expect(queryBuilder.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('returns true when at least one matching row exists', async () => {
|
||||
queryBuilder.getRawOne.mockResolvedValueOnce({ '?column?': 1 });
|
||||
|
||||
const result = await repo.existsAnyForInbox({
|
||||
projectIds: ['proj-1'],
|
||||
requesterId: 'user-1',
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(queryBuilder.select).toHaveBeenCalledWith('1');
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith(
|
||||
'(review.projectId IN (:...projectIds) OR review.createdById = :requesterId)',
|
||||
{ projectIds: ['proj-1'], requesterId: 'user-1' },
|
||||
);
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
|
||||
state: 'open',
|
||||
});
|
||||
expect(queryBuilder.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('returns false when no rows match', async () => {
|
||||
queryBuilder.getRawOne.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
repo.existsAnyForInbox({ projectIds: ['proj-1'], requesterId: 'user-1' }),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,10 @@ export {
|
||||
type PublishedWorkflowDataForExecution,
|
||||
} from './workflow-published-version.repository';
|
||||
export { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
|
||||
export { WorkflowReviewRequestRepository } from './workflow-review-request.repository';
|
||||
export {
|
||||
WorkflowReviewRequestRepository,
|
||||
type InboxCursor,
|
||||
} from './workflow-review-request.repository';
|
||||
export { WorkflowReviewRequestWorkflowRepository } from './workflow-review-request-workflow.repository';
|
||||
export { WorkflowReviewRequestReviewerRepository } from './workflow-review-request-reviewer.repository';
|
||||
export { WorkflowReviewRequestAuthorRepository } from './workflow-review-request-author.repository';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Service } from '@n8n/di';
|
||||
import type { EntityManager } from '@n8n/typeorm';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { WorkflowEntity } from '../entities/workflow-entity';
|
||||
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
|
||||
|
||||
@Service()
|
||||
@@ -36,4 +37,21 @@ export class WorkflowReviewRequestWorkflowRepository extends Repository<Workflow
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** One workflow per review for now; multi-workflow "primary" selection can wait. */
|
||||
async findWorkflowNamesByRequestIds(requestIds: string[]): Promise<Map<string, string>> {
|
||||
if (requestIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Join via entity so DB_TABLE_PREFIX is applied (postgres ITs).
|
||||
const rows = await this.createQueryBuilder('wrw')
|
||||
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = wrw.workflowId')
|
||||
.select('wrw.workflowReviewRequestId', 'requestId')
|
||||
.addSelect('workflow.name', 'workflowName')
|
||||
.where('wrw.workflowReviewRequestId IN (:...requestIds)', { requestIds })
|
||||
.getRawMany<{ requestId: string; workflowName: string }>();
|
||||
|
||||
return new Map(rows.map((row) => [row.requestId, row.workflowName]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import type { EntityManager } from '@n8n/typeorm';
|
||||
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
|
||||
@@ -9,6 +9,33 @@ import {
|
||||
type WorkflowReviewRequestState,
|
||||
} from '../entities/workflow-review-request.ee';
|
||||
|
||||
/**
|
||||
* Keyset pagination boundary. The caller carries `createdAt`/`id` in the cursor
|
||||
* itself so pagination never depends on the anchor row still existing.
|
||||
*/
|
||||
export type InboxCursor = {
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type FindManyForInboxOptions = {
|
||||
/** `null` means all projects (no filter); `[]` means no publish-scoped projects. */
|
||||
projectIds: string[] | null;
|
||||
/** Requesters always see the reviews they created, regardless of project scope. */
|
||||
requesterId: string;
|
||||
state?: WorkflowReviewRequestState;
|
||||
limit: number;
|
||||
cursor?: InboxCursor;
|
||||
};
|
||||
|
||||
export type ExistsAnyForInboxOptions = {
|
||||
/** `null` means all projects (no filter); `[]` means no publish-scoped projects. */
|
||||
projectIds: string[] | null;
|
||||
/** Requesters always see the reviews they created, regardless of project scope. */
|
||||
requesterId: string;
|
||||
state?: WorkflowReviewRequestState;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class WorkflowReviewRequestRepository extends Repository<WorkflowReviewRequest> {
|
||||
constructor(dataSource: DataSource) {
|
||||
@@ -95,4 +122,70 @@ export class WorkflowReviewRequestRepository extends Repository<WorkflowReviewRe
|
||||
.orderBy('request.createdAt', 'DESC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async findManyForInbox(options: FindManyForInboxOptions): Promise<WorkflowReviewRequest[]> {
|
||||
const { projectIds, requesterId, state, limit, cursor } = options;
|
||||
|
||||
const queryBuilder = this.createQueryBuilder('review')
|
||||
.orderBy('review.createdAt', 'DESC')
|
||||
.addOrderBy('review.id', 'ASC');
|
||||
|
||||
this.applyInboxVisibility(queryBuilder, projectIds, requesterId);
|
||||
|
||||
if (state !== undefined) {
|
||||
queryBuilder.andWhere('review.state = :state', { state });
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
queryBuilder.andWhere(
|
||||
'(review.createdAt < :createdAt OR (review.createdAt = :createdAt AND review.id > :id))',
|
||||
{ createdAt: cursor.createdAt, id: cursor.id },
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.take(limit);
|
||||
|
||||
return await queryBuilder.getMany();
|
||||
}
|
||||
|
||||
async existsAnyForInbox(options: ExistsAnyForInboxOptions): Promise<boolean> {
|
||||
const { projectIds, requesterId, state } = options;
|
||||
|
||||
const queryBuilder = this.createQueryBuilder('review').select('1');
|
||||
|
||||
this.applyInboxVisibility(queryBuilder, projectIds, requesterId);
|
||||
|
||||
if (state !== undefined) {
|
||||
queryBuilder.andWhere('review.state = :state', { state });
|
||||
}
|
||||
|
||||
const row = await queryBuilder.limit(1).getRawOne<Record<string, unknown>>();
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox visibility: a review is visible if the caller is its requester OR it
|
||||
* belongs to a project the caller can publish to. `projectIds === null`
|
||||
* means global scope (no filter). An empty `projectIds` still matches the
|
||||
* caller's own requests.
|
||||
*/
|
||||
private applyInboxVisibility(
|
||||
queryBuilder: SelectQueryBuilder<WorkflowReviewRequest>,
|
||||
projectIds: string[] | null,
|
||||
requesterId: string,
|
||||
): void {
|
||||
if (projectIds === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
queryBuilder.where('review.createdById = :requesterId', { requesterId });
|
||||
return;
|
||||
}
|
||||
|
||||
queryBuilder.where(
|
||||
'(review.projectId IN (:...projectIds) OR review.createdById = :requesterId)',
|
||||
{ projectIds, requesterId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { LicenseState, type Logger } from '@n8n/backend-common';
|
||||
import type {
|
||||
DbLockService,
|
||||
SharedWorkflowRepository,
|
||||
User,
|
||||
UserRepository,
|
||||
WorkflowReviewRequest,
|
||||
WorkflowReviewRequestReviewerRepository,
|
||||
} from '@n8n/db';
|
||||
import {
|
||||
WorkflowReviewRequestAuthorRepository,
|
||||
WorkflowReviewRequestRepository,
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CollaborationService } from '@/collaboration/collaboration.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import type { RoleService } from '@/services/role.service';
|
||||
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
|
||||
import { WorkflowReviewRequestService } from '../workflow-review-request.service';
|
||||
|
||||
describe('WorkflowReviewRequestService list', () => {
|
||||
const logger = mock<Logger>();
|
||||
const workflowReviewPolicyService = mockInstance(WorkflowReviewPolicyService);
|
||||
const workflowFinderService = mock<WorkflowFinderService>();
|
||||
const workflowHistoryService = mock<WorkflowHistoryService>();
|
||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||
const workflowReviewRequestRepository = mockInstance(WorkflowReviewRequestRepository);
|
||||
const workflowReviewRequestWorkflowRepository = mockInstance(
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
);
|
||||
const workflowReviewRequestAuthorRepository = mockInstance(WorkflowReviewRequestAuthorRepository);
|
||||
const reviewerRepository = mock<WorkflowReviewRequestReviewerRepository>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const roleService = mock<RoleService>();
|
||||
const projectService = mockInstance(ProjectService);
|
||||
const licenseState = mockInstance(LicenseState);
|
||||
const dbLockService = mock<DbLockService>();
|
||||
const collaborationService = mock<CollaborationService>();
|
||||
|
||||
let service: WorkflowReviewRequestService;
|
||||
|
||||
const user = mock<User>({ id: 'user-1', role: { slug: 'global:member', scopes: [] } });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.N8N_ENV_FEAT_WORKFLOW_REVIEWS = 'true';
|
||||
licenseState.isWorkflowReviewsLicensed.mockReturnValue(true);
|
||||
workflowReviewPolicyService.get.mockResolvedValue({ enabled: true });
|
||||
|
||||
service = new WorkflowReviewRequestService(
|
||||
logger,
|
||||
workflowReviewPolicyService,
|
||||
workflowFinderService,
|
||||
workflowHistoryService,
|
||||
sharedWorkflowRepository,
|
||||
workflowReviewRequestRepository,
|
||||
workflowReviewRequestWorkflowRepository,
|
||||
workflowReviewRequestAuthorRepository,
|
||||
reviewerRepository,
|
||||
userRepository,
|
||||
roleService,
|
||||
projectService,
|
||||
licenseState,
|
||||
dbLockService,
|
||||
collaborationService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('listForInbox', () => {
|
||||
function mockAccessibleProjects(projectIds: string[] = ['proj-1']) {
|
||||
projectService.getProjectIdsWithScope.mockResolvedValueOnce(projectIds);
|
||||
}
|
||||
|
||||
it('returns paginated data with hasMore and nextCursor', async () => {
|
||||
mockAccessibleProjects();
|
||||
const rows = [
|
||||
mock<WorkflowReviewRequest>({
|
||||
id: 'req-2',
|
||||
projectId: 'proj-1',
|
||||
title: 'Second',
|
||||
decision: 'pending',
|
||||
state: 'open',
|
||||
createdAt: new Date('2024-01-02T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
|
||||
}),
|
||||
mock<WorkflowReviewRequest>({
|
||||
id: 'req-1',
|
||||
projectId: 'proj-1',
|
||||
title: 'First',
|
||||
decision: 'pending',
|
||||
state: 'open',
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
];
|
||||
workflowReviewRequestRepository.findManyForInbox.mockResolvedValue(rows);
|
||||
workflowReviewRequestWorkflowRepository.findWorkflowNamesByRequestIds.mockResolvedValue(
|
||||
new Map([['req-2', 'Linked workflow']]),
|
||||
);
|
||||
|
||||
const result = await service.listForInbox(user, { limit: 1 });
|
||||
|
||||
expect(workflowReviewRequestRepository.findManyForInbox).toHaveBeenCalledWith({
|
||||
projectIds: ['proj-1'],
|
||||
requesterId: 'user-1',
|
||||
state: 'open',
|
||||
limit: 2,
|
||||
cursor: undefined,
|
||||
});
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]?.workflowName).toBe('Linked workflow');
|
||||
expect(result.hasMore).toBe(true);
|
||||
// nextCursor encodes the last row's keyset boundary (createdAt + id).
|
||||
const expectedCursor = Buffer.from('2024-01-02T00:00:00.000Z|req-2', 'utf8').toString(
|
||||
'base64url',
|
||||
);
|
||||
expect(result.nextCursor).toBe(expectedCursor);
|
||||
});
|
||||
|
||||
it('decodes the incoming cursor into a keyset boundary', async () => {
|
||||
mockAccessibleProjects();
|
||||
workflowReviewRequestRepository.findManyForInbox.mockResolvedValue([]);
|
||||
workflowReviewRequestWorkflowRepository.findWorkflowNamesByRequestIds.mockResolvedValue(
|
||||
new Map(),
|
||||
);
|
||||
const cursor = Buffer.from('2024-01-02T00:00:00.000Z|req-2', 'utf8').toString('base64url');
|
||||
|
||||
await service.listForInbox(user, { limit: 15, cursor });
|
||||
|
||||
expect(workflowReviewRequestRepository.findManyForInbox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursor: { createdAt: new Date('2024-01-02T00:00:00.000Z'), id: 'req-2' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a malformed cursor', async () => {
|
||||
mockAccessibleProjects();
|
||||
const cursor = Buffer.from('not-a-valid-cursor', 'utf8').toString('base64url');
|
||||
|
||||
await expect(service.listForInbox(user, { limit: 15, cursor })).rejects.toThrow(
|
||||
'Invalid pagination cursor',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAccessibleProjectIds', () => {
|
||||
it('returns the publish-scoped project ids for members', async () => {
|
||||
projectService.getProjectIdsWithScope.mockResolvedValueOnce(['publish-proj']);
|
||||
|
||||
expect(await service.resolveAccessibleProjectIds(user)).toEqual(['publish-proj']);
|
||||
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(user, [
|
||||
'workflow:publish',
|
||||
]);
|
||||
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledTimes(1);
|
||||
// Requesters see their own reviews via the repository's requesterId filter,
|
||||
// so no personal-project fallback is needed here.
|
||||
expect(projectService.getPersonalProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null (all projects) for users with global workflow:publish without enumerating projects', async () => {
|
||||
const owner = mock<User>({
|
||||
role: {
|
||||
slug: 'global:owner',
|
||||
scopes: [{ slug: 'workflow:publish' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await service.resolveAccessibleProjectIds(owner)).toBeNull();
|
||||
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not short-circuit for a global project:delete role without workflow:publish', async () => {
|
||||
const admin = mock<User>({
|
||||
role: {
|
||||
slug: 'custom:global',
|
||||
scopes: [{ slug: 'project:delete' }],
|
||||
},
|
||||
});
|
||||
projectService.getProjectIdsWithScope.mockResolvedValueOnce(['publish-proj']);
|
||||
|
||||
expect(await service.resolveAccessibleProjectIds(admin)).toEqual(['publish-proj']);
|
||||
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(admin, [
|
||||
'workflow:publish',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-1
@@ -3,7 +3,7 @@ import type {
|
||||
GetWorkflowReviewEligibleReviewersQueryDto,
|
||||
ListWorkflowReviewRequestsQueryDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { LicenseState, Logger } from '@n8n/backend-common';
|
||||
import type {
|
||||
AuthIdentity,
|
||||
DbLockService,
|
||||
@@ -26,6 +26,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
import type { RoleService } from '@/services/role.service';
|
||||
import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
@@ -59,6 +60,8 @@ describe('WorkflowReviewRequestService', () => {
|
||||
const reviewerRepository = mock<WorkflowReviewRequestReviewerRepository>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const roleService = mock<RoleService>();
|
||||
const projectService = mock<ProjectService>();
|
||||
const licenseState = mock<LicenseState>();
|
||||
const dbLockService = mock<DbLockService>();
|
||||
const collaborationService = mock<CollaborationService>();
|
||||
const logger = mock<Logger>();
|
||||
@@ -76,12 +79,16 @@ describe('WorkflowReviewRequestService', () => {
|
||||
reviewerRepository,
|
||||
userRepository,
|
||||
roleService,
|
||||
projectService,
|
||||
licenseState,
|
||||
dbLockService,
|
||||
collaborationService,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.N8N_ENV_FEAT_WORKFLOW_REVIEWS = 'true';
|
||||
licenseState.isWorkflowReviewsLicensed.mockReturnValue(true);
|
||||
// Feature enabled by default; the disabled path is exercised explicitly.
|
||||
workflowReviewPolicyService.get.mockResolvedValue({ enabled: true });
|
||||
// By default, run the critical section against the mocked transaction.
|
||||
|
||||
+238
-2
@@ -15,13 +15,13 @@ import {
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
|
||||
import { createAdmin, createMember, createOwner, createUser } from '@test-integration/db/users';
|
||||
import { createWorkflowHistoryItem } from '@test-integration/db/workflow-history';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import * as utils from '@test-integration/utils';
|
||||
|
||||
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['workflow-reviews'],
|
||||
enabledFeatures: ['feat:workflowReviews'],
|
||||
@@ -30,9 +30,12 @@ const testServer = utils.setupTestServer({
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let viewer: User;
|
||||
let ownerProject: Project;
|
||||
let teamProject: Project;
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let memberAgent: SuperAgentTest;
|
||||
let viewerAgent: SuperAgentTest;
|
||||
|
||||
let requestRepository: WorkflowReviewRequestRepository;
|
||||
let workflowRepository: WorkflowReviewRequestWorkflowRepository;
|
||||
@@ -49,6 +52,9 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.N8N_ENV_FEAT_WORKFLOW_REVIEWS = 'true';
|
||||
testServer.license.enable('feat:workflowReviews');
|
||||
|
||||
await testDb.truncate([
|
||||
'WorkflowReviewRequestAuthor',
|
||||
'WorkflowReviewRequestReviewer',
|
||||
@@ -68,9 +74,15 @@ beforeEach(async () => {
|
||||
|
||||
owner = await createOwner();
|
||||
member = await createMember();
|
||||
viewer = await createMember();
|
||||
ownerProject = await getPersonalProject(owner);
|
||||
teamProject = await createTeamProject('Reviews Project', owner);
|
||||
await linkUserToProject(member, teamProject, 'project:editor');
|
||||
await linkUserToProject(viewer, teamProject, 'project:viewer');
|
||||
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
memberAgent = testServer.authAgentFor(member);
|
||||
viewerAgent = testServer.authAgentFor(viewer);
|
||||
});
|
||||
|
||||
/** Create a workflow owned by `owner` with a pinned history version. */
|
||||
@@ -742,3 +754,227 @@ describe('GET /workflow-review-requests', () => {
|
||||
testServer.license.enable('feat:workflowReviews');
|
||||
});
|
||||
});
|
||||
|
||||
async function seedInboxRequests() {
|
||||
const openRequest = await requestRepository.createRequest({
|
||||
projectId: teamProject.id,
|
||||
title: 'Open review request',
|
||||
createdById: owner.id,
|
||||
state: 'open',
|
||||
});
|
||||
const closedRequest = await requestRepository.createRequest({
|
||||
projectId: teamProject.id,
|
||||
title: 'Closed review request',
|
||||
createdById: owner.id,
|
||||
state: 'closed',
|
||||
});
|
||||
return { openRequest, closedRequest };
|
||||
}
|
||||
|
||||
describe('GET /workflow-review-requests/summary', () => {
|
||||
test('returns hasAny true for instance owner', async () => {
|
||||
await seedInboxRequests();
|
||||
|
||||
const response = await ownerAgent.get('/workflow-review-requests/summary').expect(200);
|
||||
|
||||
expect(response.body.data).toEqual({ hasAny: true });
|
||||
});
|
||||
|
||||
test('returns hasAny true for project editor', async () => {
|
||||
await seedInboxRequests();
|
||||
|
||||
const response = await memberAgent.get('/workflow-review-requests/summary').expect(200);
|
||||
|
||||
expect(response.body.data).toEqual({ hasAny: true });
|
||||
});
|
||||
|
||||
test('returns hasAny false for project viewer', async () => {
|
||||
await seedInboxRequests();
|
||||
|
||||
const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200);
|
||||
|
||||
expect(response.body.data).toEqual({ hasAny: false });
|
||||
});
|
||||
|
||||
test('returns hasAny true for a requester of their own review', async () => {
|
||||
await requestRepository.createRequest({
|
||||
projectId: teamProject.id,
|
||||
title: 'Review submitted by viewer',
|
||||
createdById: viewer.id,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200);
|
||||
|
||||
expect(response.body.data).toEqual({ hasAny: true });
|
||||
});
|
||||
|
||||
test('returns 403 when feature is disabled', async () => {
|
||||
await policyService.set(false);
|
||||
|
||||
await ownerAgent.get('/workflow-review-requests/summary').expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workflow-review-requests/inbox', () => {
|
||||
test('returns reviews for instance owner', async () => {
|
||||
const { openRequest } = await seedInboxRequests();
|
||||
|
||||
const response = await ownerAgent
|
||||
.get('/workflow-review-requests/inbox')
|
||||
.query({ state: 'open', limit: 15 })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.data).toHaveLength(1);
|
||||
expect(response.body.data.data[0]).toMatchObject({
|
||||
id: openRequest.id,
|
||||
title: 'Open review request',
|
||||
state: 'open',
|
||||
workflowName: null,
|
||||
});
|
||||
expect(response.body.data.hasMore).toBe(false);
|
||||
expect(response.body.data.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
test('returns empty list for project viewer', async () => {
|
||||
await seedInboxRequests();
|
||||
|
||||
const response = await viewerAgent.get('/workflow-review-requests/inbox').expect(200);
|
||||
|
||||
expect(response.body.data.data).toEqual([]);
|
||||
expect(response.body.data.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
test('returns 403 when license is disabled', async () => {
|
||||
testServer.license.disable('feat:workflowReviews');
|
||||
|
||||
await ownerAgent.get('/workflow-review-requests/inbox').expect(403);
|
||||
});
|
||||
|
||||
test('returns cursor pagination metadata', async () => {
|
||||
await seedInboxRequests();
|
||||
await requestRepository.createRequest({
|
||||
projectId: teamProject.id,
|
||||
title: 'Second open review',
|
||||
createdById: owner.id,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const firstPage = await ownerAgent
|
||||
.get('/workflow-review-requests/inbox')
|
||||
.query({ state: 'open', limit: 1 })
|
||||
.expect(200);
|
||||
|
||||
expect(firstPage.body.data.data).toHaveLength(1);
|
||||
expect(firstPage.body.data.hasMore).toBe(true);
|
||||
expect(firstPage.body.data.nextCursor).toBeTruthy();
|
||||
|
||||
const secondPage = await ownerAgent
|
||||
.get('/workflow-review-requests/inbox')
|
||||
.query({
|
||||
state: 'open',
|
||||
limit: 1,
|
||||
cursor: firstPage.body.data.nextCursor,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(secondPage.body.data.data).toHaveLength(1);
|
||||
expect(secondPage.body.data.data[0].id).not.toBe(firstPage.body.data.data[0].id);
|
||||
});
|
||||
|
||||
test('includes workflow name on list items', async () => {
|
||||
const workflow = await createWorkflow({ name: 'Inbox Workflow' }, teamProject);
|
||||
const enrichedRequest = await requestRepository.createRequest({
|
||||
projectId: teamProject.id,
|
||||
title: 'Enriched review request',
|
||||
createdById: owner.id,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
await workflowRepository.createWorkflowRow({
|
||||
workflowReviewRequestId: enrichedRequest.id,
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
const response = await ownerAgent
|
||||
.get('/workflow-review-requests/inbox')
|
||||
.query({ state: 'open', limit: 15 })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.data).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: enrichedRequest.id,
|
||||
title: 'Enriched review request',
|
||||
workflowName: 'Inbox Workflow',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('hides reviews from projects the member cannot access', async () => {
|
||||
const otherProject = await createTeamProject('Other Reviews Project', owner);
|
||||
await requestRepository.createRequest({
|
||||
projectId: otherProject.id,
|
||||
title: 'Private other-project review',
|
||||
createdById: owner.id,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const memberResponse = await memberAgent.get('/workflow-review-requests/inbox').expect(200);
|
||||
expect(memberResponse.body.data.data).toEqual([]);
|
||||
|
||||
const ownerResponse = await ownerAgent.get('/workflow-review-requests/inbox').expect(200);
|
||||
expect(ownerResponse.body.data.data).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Private other-project review',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows requesters their own reviews even without project access', async () => {
|
||||
const otherProject = await createTeamProject('Unrelated Project', owner);
|
||||
const ownRequest = await requestRepository.createRequest({
|
||||
projectId: otherProject.id,
|
||||
title: 'Review I submitted',
|
||||
createdById: member.id,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const response = await memberAgent.get('/workflow-review-requests/inbox').expect(200);
|
||||
|
||||
expect(response.body.data.data).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: ownRequest.id })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not truncate pagination when the cursor row is deleted', async () => {
|
||||
await seedInboxRequests();
|
||||
await requestRepository.createRequest({
|
||||
projectId: teamProject.id,
|
||||
title: 'Second open review',
|
||||
createdById: owner.id,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const firstPage = await ownerAgent
|
||||
.get('/workflow-review-requests/inbox')
|
||||
.query({ state: 'open', limit: 1 })
|
||||
.expect(200);
|
||||
const cursor = firstPage.body.data.nextCursor as string;
|
||||
const firstId = firstPage.body.data.data[0].id as string;
|
||||
|
||||
// Delete the anchor row before requesting the next page.
|
||||
await requestRepository.delete({ id: firstId });
|
||||
|
||||
const secondPage = await ownerAgent
|
||||
.get('/workflow-review-requests/inbox')
|
||||
.query({ state: 'open', limit: 1, cursor })
|
||||
.expect(200);
|
||||
|
||||
expect(secondPage.body.data.data).toHaveLength(1);
|
||||
expect(secondPage.body.data.data[0].id).not.toBe(firstId);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-2
@@ -21,8 +21,8 @@ beforeEach(async () => {
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
});
|
||||
|
||||
describe('POST /workflow-review-requests (env flag off)', () => {
|
||||
test('is unreachable (404) even though the license is present', async () => {
|
||||
describe('workflow-review-requests (env flag off)', () => {
|
||||
test('POST is unreachable (404) even though the license is present', async () => {
|
||||
await ownerAgent
|
||||
.post('/workflow-review-requests')
|
||||
.send({
|
||||
@@ -31,6 +31,14 @@ describe('POST /workflow-review-requests (env flag off)', () => {
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
test('GET inbox is unreachable (404)', async () => {
|
||||
await ownerAgent.get('/workflow-review-requests/inbox').expect(404);
|
||||
});
|
||||
|
||||
test('GET summary is unreachable (404)', async () => {
|
||||
await ownerAgent.get('/workflow-review-requests/summary').expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workflow-review-requests (env flag off)', () => {
|
||||
|
||||
@@ -5,8 +5,12 @@ import type {
|
||||
WorkflowReviewEligibleReviewersList,
|
||||
WorkflowReviewRequestList,
|
||||
WorkflowReviewRequestSummary,
|
||||
ListWorkflowReviewInboxQueryDto,
|
||||
ListWorkflowReviewInboxResponse,
|
||||
GetWorkflowReviewInboxSummaryResponse,
|
||||
WorkflowReviewInboxItem,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { LicenseState, Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
DbLock,
|
||||
DbLockService,
|
||||
@@ -16,15 +20,20 @@ import {
|
||||
WorkflowReviewRequestRepository,
|
||||
WorkflowReviewRequestReviewerRepository,
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
type InboxCursor,
|
||||
type User,
|
||||
type WorkflowReviewRequest,
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
|
||||
import { CollaborationService } from '@/collaboration/collaboration.service';
|
||||
import { isWorkflowReviewsFeatureAvailable } from '@/constants/workflow-reviews';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
@@ -44,6 +53,8 @@ export class WorkflowReviewRequestService {
|
||||
private readonly workflowReviewRequestReviewerRepository: WorkflowReviewRequestReviewerRepository,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly roleService: RoleService,
|
||||
private readonly projectService: ProjectService,
|
||||
private readonly licenseState: LicenseState,
|
||||
private readonly dbLockService: DbLockService,
|
||||
private readonly collaborationService: CollaborationService,
|
||||
) {}
|
||||
@@ -253,4 +264,121 @@ export class WorkflowReviewRequestService {
|
||||
updatedAt: request.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-project inbox.
|
||||
*
|
||||
* Defaults to open requests when `state` is omitted. Deferred (LIGO-594):
|
||||
* `projectId`, `reviewer`, and `author` filters.
|
||||
*/
|
||||
async listForInbox(
|
||||
user: User,
|
||||
query: ListWorkflowReviewInboxQueryDto,
|
||||
): Promise<ListWorkflowReviewInboxResponse> {
|
||||
await this.assertFeatureAvailable();
|
||||
|
||||
const projectIds = await this.resolveAccessibleProjectIds(user);
|
||||
const { limit } = query;
|
||||
const rows = await this.workflowReviewRequestRepository.findManyForInbox({
|
||||
projectIds,
|
||||
requesterId: user.id,
|
||||
state: query.state ?? 'open',
|
||||
limit: limit + 1,
|
||||
cursor: query.cursor ? this.decodeInboxCursor(query.cursor) : undefined,
|
||||
});
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const data = rows.slice(0, limit);
|
||||
const lastRow = data.at(-1);
|
||||
const nextCursor = hasMore && lastRow ? this.encodeInboxCursor(lastRow) : null;
|
||||
const workflowNamesByRequestId =
|
||||
await this.workflowReviewRequestWorkflowRepository.findWorkflowNamesByRequestIds(
|
||||
data.map((row) => row.id),
|
||||
);
|
||||
|
||||
return {
|
||||
data: data.map((row) => this.toInboxItem(row, workflowNamesByRequestId.get(row.id) ?? null)),
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
async getInboxSummaryForUser(user: User): Promise<GetWorkflowReviewInboxSummaryResponse> {
|
||||
await this.assertFeatureAvailable();
|
||||
|
||||
const projectIds = await this.resolveAccessibleProjectIds(user);
|
||||
// Any state — sidebar must appear so users can open the Closed tab.
|
||||
const hasAny = await this.workflowReviewRequestRepository.existsAnyForInbox({
|
||||
projectIds,
|
||||
requesterId: user.id,
|
||||
});
|
||||
|
||||
return { hasAny };
|
||||
}
|
||||
|
||||
private async assertFeatureAvailable(): Promise<void> {
|
||||
if (!isWorkflowReviewsFeatureAvailable(this.licenseState.isWorkflowReviewsLicensed())) {
|
||||
throw new ForbiddenError('Workflow reviews are not available on this instance');
|
||||
}
|
||||
|
||||
const policy = await this.workflowReviewPolicyService.get();
|
||||
if (!policy.enabled) {
|
||||
throw new ForbiddenError('Workflow reviews are disabled on this instance');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project IDs for inbox queries. `null` means "all projects, unfiltered" —
|
||||
* correct for users with `workflow:publish` scoped globally. Requesters always
|
||||
* see their own reviews regardless (repository OR-matches `requesterId`), so no
|
||||
* personal-project fallback is needed.
|
||||
*/
|
||||
async resolveAccessibleProjectIds(user: User): Promise<string[] | null> {
|
||||
if (hasGlobalScope(user, 'workflow:publish')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.projectService.getProjectIdsWithScope(user, ['workflow:publish']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the keyset boundary (createdAt + id) into an opaque cursor so the
|
||||
* next page is resolved without re-reading the anchor row — a review deleted
|
||||
* between requests no longer truncates the rest of the inbox.
|
||||
*/
|
||||
private encodeInboxCursor(row: WorkflowReviewRequest): string {
|
||||
return Buffer.from(`${row.createdAt.toISOString()}|${row.id}`, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
private decodeInboxCursor(cursor: string): InboxCursor {
|
||||
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||
const separatorIndex = decoded.indexOf('|');
|
||||
if (separatorIndex === -1) {
|
||||
throw new BadRequestError('Invalid pagination cursor');
|
||||
}
|
||||
|
||||
const createdAt = new Date(decoded.slice(0, separatorIndex));
|
||||
const id = decoded.slice(separatorIndex + 1);
|
||||
if (id.length === 0 || Number.isNaN(createdAt.getTime())) {
|
||||
throw new BadRequestError('Invalid pagination cursor');
|
||||
}
|
||||
|
||||
return { createdAt, id };
|
||||
}
|
||||
|
||||
private toInboxItem(
|
||||
entity: WorkflowReviewRequest,
|
||||
workflowName: string | null,
|
||||
): WorkflowReviewInboxItem {
|
||||
return {
|
||||
id: entity.id,
|
||||
projectId: entity.projectId,
|
||||
title: entity.title,
|
||||
workflowName,
|
||||
decision: entity.decision,
|
||||
state: entity.state,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -2,10 +2,13 @@ import {
|
||||
CreateWorkflowReviewRequestDto,
|
||||
GetWorkflowReviewEligibleReviewersQueryDto,
|
||||
ListWorkflowReviewRequestsQueryDto,
|
||||
type GetWorkflowReviewInboxSummaryResponse,
|
||||
type ListWorkflowReviewInboxResponse,
|
||||
ListWorkflowReviewInboxQueryDto,
|
||||
} from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Get, Licensed, Post, Query, RestController } from '@n8n/decorators';
|
||||
import { Response } from 'express';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { WorkflowReviewRequestService } from './workflow-review-request.service';
|
||||
|
||||
@@ -46,4 +49,27 @@ export class WorkflowReviewRequestsController {
|
||||
res.status(201);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-project inbox. Lives under `/inbox` so `GET /` stays free for the
|
||||
* workflow-scoped list used by review-required toggle sync (LIGO-838).
|
||||
*/
|
||||
@Get('/inbox')
|
||||
@Licensed('feat:workflowReviews')
|
||||
async listInbox(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Query query: ListWorkflowReviewInboxQueryDto,
|
||||
): Promise<ListWorkflowReviewInboxResponse> {
|
||||
return await this.workflowReviewRequestService.listForInbox(req.user, query);
|
||||
}
|
||||
|
||||
@Get('/summary')
|
||||
@Licensed('feat:workflowReviews')
|
||||
async getSummary(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
): Promise<GetWorkflowReviewInboxSummaryResponse> {
|
||||
return await this.workflowReviewRequestService.getInboxSummaryForUser(req.user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const scrollRight = () => scroll(50);
|
||||
<div v-if="canScrollRight" :class="$style.next" @click="scrollRight">
|
||||
<N8nIcon :class="$style.positionIcon" icon="chevron-right" size="small" />
|
||||
</div>
|
||||
<div ref="tabs" :class="$style.tabs">
|
||||
<div ref="tabs" role="tablist" :class="$style.tabs">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:id="option.value.toString()"
|
||||
@@ -133,6 +133,9 @@ const scrollRight = () => scroll(50);
|
||||
</RouterLink>
|
||||
<div
|
||||
v-else
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="modelValue === option.value"
|
||||
:class="{
|
||||
[$style.tab]: true,
|
||||
[$style.activeTab]: modelValue === option.value,
|
||||
@@ -140,6 +143,8 @@ const scrollRight = () => scroll(50);
|
||||
[$style.dangerTab]: option.variant === 'danger',
|
||||
}"
|
||||
@click="() => handleTabClick(option.value)"
|
||||
@keydown.enter.prevent="() => handleTabClick(option.value)"
|
||||
@keydown.space.prevent="() => handleTabClick(option.value)"
|
||||
>
|
||||
<N8nIcon
|
||||
v-if="option.icon && option.iconPosition !== 'right'"
|
||||
|
||||
@@ -5090,6 +5090,16 @@
|
||||
"settings.security.workflowReviews.error": "Failed to update workflow reviews settings",
|
||||
"settings.security.workflowReviews.confirmDisable.headline": "Disable workflow reviews",
|
||||
"settings.security.workflowReviews.confirmDisable.message": "Workflow reviews will no longer be available on this instance. Existing project-level review settings will be ignored.",
|
||||
"workflowReviews.menu.title": "Reviews",
|
||||
"workflowReviews.page.title": "Reviews",
|
||||
"workflowReviews.disclaimer.body": "Submit workflow versions for review before publishing. When reviews are assigned to your projects, they will appear here.",
|
||||
"workflowReviews.sidebar.tabs.open": "Open",
|
||||
"workflowReviews.sidebar.tabs.closed": "Closed",
|
||||
"workflowReviews.sidebar.empty": "No reviews in this tab",
|
||||
"workflowReviews.detail.placeholder": "Full review details are coming soon.",
|
||||
"workflowReviews.emptyState.body": "There are no reviews in this tab.",
|
||||
"workflowReviews.noSelection.body": "Select a review to see more information.",
|
||||
"workflowReviews.error.load": "Could not load workflow reviews",
|
||||
"settings.security.personalSpace.title": "Personal Space",
|
||||
"settings.security.personalSpace.description": "Manage workflow publishing and sharing in personal spaces.",
|
||||
"settings.security.personalSpace.publishing.title": "Workflow publishing",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChatModule } from '@/features/ai/chatHub/module.descriptor';
|
||||
import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor';
|
||||
import { AgentsModule } from '@/features/agents/module.descriptor';
|
||||
import { OtelModule } from '@/features/settings/otel/module.descriptor';
|
||||
import { WorkflowReviewsModule } from '@/features/workflow-reviews/module.descriptor';
|
||||
|
||||
/**
|
||||
* Hard-coding modules list until we have a dynamic way to load modules.
|
||||
@@ -18,4 +19,5 @@ export const modules: FrontendModuleDescription[] = [
|
||||
InstanceAiModule,
|
||||
AgentsModule,
|
||||
OtelModule,
|
||||
WorkflowReviewsModule,
|
||||
];
|
||||
|
||||
+18
@@ -15,6 +15,8 @@ import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
import { useFavoriteNavItems } from '../composables/useFavoriteNavItems';
|
||||
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
|
||||
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants';
|
||||
import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature';
|
||||
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
|
||||
@@ -123,6 +125,15 @@ const instanceAi = computed<IMenuItem>(() => ({
|
||||
preview: true,
|
||||
}));
|
||||
|
||||
const { isWorkflowReviewsEnabled: isWorkflowReviewsNavVisible } = useWorkflowReviewsFeature();
|
||||
|
||||
const workflowReviews = computed<IMenuItem>(() => ({
|
||||
id: 'workflow-reviews',
|
||||
icon: 'message-square-text',
|
||||
label: locale.baseText('workflowReviews.menu.title'),
|
||||
route: { to: { name: WORKFLOW_REVIEW_REQUESTS_VIEW } },
|
||||
preview: true,
|
||||
}));
|
||||
const chat = computed<IMenuItem>(() => ({
|
||||
id: 'chat',
|
||||
icon: 'message-circle',
|
||||
@@ -180,6 +191,13 @@ onBeforeUnmount(() => {
|
||||
:active="activeTabId === 'shared'"
|
||||
data-test-id="project-shared-menu-item"
|
||||
/>
|
||||
<N8nMenuItem
|
||||
v-if="isWorkflowReviewsNavVisible"
|
||||
:item="workflowReviews"
|
||||
:compact="props.collapsed"
|
||||
:active="activeTabId === 'workflow-reviews'"
|
||||
data-test-id="project-workflow-reviews-menu-item"
|
||||
/>
|
||||
<N8nMenuItem
|
||||
v-if="isChatLinkAvailable"
|
||||
:item="chat"
|
||||
|
||||
+5
@@ -1,6 +1,8 @@
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants';
|
||||
|
||||
/**
|
||||
* This composable holds reusable logic that detects the current page type
|
||||
*/
|
||||
@@ -21,9 +23,12 @@ export const useProjectPages = () => {
|
||||
route.name === VIEWS.SHARED_CREDENTIALS,
|
||||
);
|
||||
|
||||
const isWorkflowReviewsSubPage = computed(() => route.name === WORKFLOW_REVIEW_REQUESTS_VIEW);
|
||||
|
||||
return reactive({
|
||||
isOverviewSubPage,
|
||||
isSharedSubPage,
|
||||
isProjectsSubPage,
|
||||
isWorkflowReviewsSubPage,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -327,6 +327,11 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
||||
setCurrentProject(null);
|
||||
}
|
||||
|
||||
if (newRoute?.path?.includes('workflow-review-requests')) {
|
||||
projectNavActiveId.value = 'workflow-reviews';
|
||||
setCurrentProject(null);
|
||||
}
|
||||
|
||||
if (newRoute?.path?.includes('workflow/')) {
|
||||
if (currentProjectId.value) {
|
||||
projectNavActiveId.value = currentProjectId.value;
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
<script lang="ts" setup>
|
||||
import type { WorkflowReviewInboxItem, WorkflowReviewRequestState } from '@n8n/api-types';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
N8nBadge,
|
||||
N8nCard,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nLoading,
|
||||
N8nTabs,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
items: WorkflowReviewInboxItem[];
|
||||
activeState: WorkflowReviewRequestState;
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
isEmpty: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string];
|
||||
clear: [];
|
||||
'update:activeState': [state: WorkflowReviewRequestState];
|
||||
loadMore: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
|
||||
const tabOptions = computed(() => [
|
||||
{ label: i18n.baseText('workflowReviews.sidebar.tabs.open'), value: 'open' as const },
|
||||
{ label: i18n.baseText('workflowReviews.sidebar.tabs.closed'), value: 'closed' as const },
|
||||
]);
|
||||
|
||||
const { observe: observeForLoadMore } = useIntersectionObserver({
|
||||
root: listRef,
|
||||
threshold: 0.01,
|
||||
onIntersect: () => emit('loadMore'),
|
||||
});
|
||||
|
||||
watch(
|
||||
[loadMoreSentinel, () => props.hasMore, () => props.loadingMore, () => props.items.length],
|
||||
([sentinel, hasMore, loadingMore]) => {
|
||||
if (sentinel && hasMore && !loadingMore) {
|
||||
observeForLoadMore(sentinel);
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
function onTabChange(value: string | number | boolean) {
|
||||
emit('update:activeState', String(value) as WorkflowReviewRequestState);
|
||||
}
|
||||
|
||||
function onListBackgroundClick() {
|
||||
if (props.selectedId) {
|
||||
emit('clear');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="$style.sidebar" data-test-id="workflow-reviews-sidebar">
|
||||
<div :class="$style.columnTitle">
|
||||
<N8nHeading bold tag="h2" size="xlarge" data-test-id="workflow-reviews-page-title">
|
||||
{{ i18n.baseText('workflowReviews.page.title') }}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
<div :class="$style.header">
|
||||
<N8nTabs
|
||||
:model-value="activeState"
|
||||
:options="tabOptions"
|
||||
variant="modern"
|
||||
data-test-id="workflow-reviews-tabs"
|
||||
@update:model-value="onTabChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="listRef"
|
||||
role="listbox"
|
||||
:aria-label="i18n.baseText('workflowReviews.page.title')"
|
||||
:class="$style.list"
|
||||
@click.self="onListBackgroundClick"
|
||||
>
|
||||
<N8nLoading v-if="loading" :loading="true" :rows="4" />
|
||||
<template v-else>
|
||||
<N8nText
|
||||
v-if="isEmpty"
|
||||
color="text-light"
|
||||
size="small"
|
||||
data-test-id="workflow-reviews-empty"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.sidebar.empty') }}
|
||||
</N8nText>
|
||||
<N8nCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:class="[$style.card, { [$style.cardSelected]: selectedId === item.id }]"
|
||||
data-test-id="workflow-review-request-row"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
:aria-selected="selectedId === item.id"
|
||||
@click="emit('select', item.id)"
|
||||
@keydown.enter.prevent="emit('select', item.id)"
|
||||
@keydown.space.prevent="emit('select', item.id)"
|
||||
>
|
||||
<div :class="$style.cardContent">
|
||||
<N8nText bold tag="h3" :class="$style.cardTitle">
|
||||
{{ item.title }}
|
||||
</N8nText>
|
||||
<div :class="$style.cardMeta">
|
||||
<N8nBadge
|
||||
v-if="item.workflowName"
|
||||
theme="tertiary"
|
||||
:show-border="false"
|
||||
:class="$style.workflowBadge"
|
||||
data-test-id="workflow-review-request-workflow-badge"
|
||||
>
|
||||
<span :class="$style.workflowBadgeText" :title="item.workflowName">
|
||||
<N8nIcon icon="workflow" size="small" />
|
||||
<span>{{ item.workflowName }}</span>
|
||||
</span>
|
||||
</N8nBadge>
|
||||
<N8nText
|
||||
size="xsmall"
|
||||
color="text-light"
|
||||
:class="$style.cardMetaTime"
|
||||
data-test-id="workflow-review-request-created-at"
|
||||
>
|
||||
<TimeAgo :date="item.createdAt" />
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</N8nCard>
|
||||
<div v-if="loadingMore" :class="$style.loadingMore">
|
||||
<N8nLoading :loading="true" :rows="1" />
|
||||
</div>
|
||||
<div ref="loadMoreSentinel" :class="$style.sentinel" />
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 35%;
|
||||
min-width: 12rem;
|
||||
height: 100%;
|
||||
border-right: var(--border-width) solid var(--color--foreground--tint-1);
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--spacing--2xl);
|
||||
padding: 0 var(--spacing--md) var(--spacing--sm) 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding: 0 var(--spacing--md) var(--spacing--md) 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--spacing--md) var(--spacing--md) 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
cursor: pointer;
|
||||
padding: var(--spacing--xs);
|
||||
align-items: stretch;
|
||||
border: var(--border-width) solid var(--color--foreground--tint-1);
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
&:hover:not(.cardSelected) {
|
||||
background-color: var(--background--active);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border-color: var(--focus--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.cardSelected {
|
||||
background-color: var(--background--active);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
font-size: var(--font-size--sm);
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardMetaTime {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.workflowBadge {
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
color: var(--color--text);
|
||||
max-width: 12rem;
|
||||
|
||||
> span {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.workflowBadgeText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
line-height: calc(var(--font-size--sm) + 1px);
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
padding: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const WORKFLOW_REVIEW_REQUESTS_VIEW = 'WorkflowReviewRequestsView';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
|
||||
|
||||
import { VIEWS } from '@/app/constants';
|
||||
|
||||
import { useWorkflowReviewsFeature } from './composables/useWorkflowReviewsFeature';
|
||||
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './constants';
|
||||
|
||||
const WorkflowReviewRequestsView = async () =>
|
||||
await import('./views/WorkflowReviewRequestsView.vue');
|
||||
|
||||
export const WorkflowReviewsModule: FrontendModuleDescription = {
|
||||
id: 'workflow-reviews',
|
||||
name: 'Workflow Reviews',
|
||||
description: 'Cross-project workflow review inbox.',
|
||||
icon: 'message-square-text',
|
||||
routes: [
|
||||
{
|
||||
path: '/workflow-review-requests',
|
||||
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
|
||||
component: WorkflowReviewRequestsView,
|
||||
beforeEnter() {
|
||||
return (
|
||||
useWorkflowReviewsFeature().isWorkflowReviewsEnabled.value || {
|
||||
name: VIEWS.NOT_FOUND,
|
||||
}
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
layout: 'default',
|
||||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as workflowReviewsApi from './workflowReviews.api';
|
||||
import { useReviewInboxStore } from './reviewInbox.store';
|
||||
|
||||
vi.mock('./workflowReviews.api');
|
||||
|
||||
describe('useReviewInboxStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('probes summary and loads open reviews when hasAny is true', async () => {
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({
|
||||
hasAny: true,
|
||||
});
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'req-1',
|
||||
projectId: 'proj-1',
|
||||
title: 'Review',
|
||||
workflowName: 'My workflow',
|
||||
decision: 'pending',
|
||||
state: 'open',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
await store.probeInbox();
|
||||
|
||||
expect(workflowReviewsApi.fetchWorkflowReviewInboxSummary).toHaveBeenCalledTimes(1);
|
||||
expect(store.hasAnyReviews).toBe(true);
|
||||
expect(store.probeSettled).toBe(true);
|
||||
expect(store.showSidebar).toBe(true);
|
||||
expect(store.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips list fetch when summary hasAny is false', async () => {
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({
|
||||
hasAny: false,
|
||||
});
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
await store.probeInbox();
|
||||
|
||||
expect(workflowReviewsApi.fetchWorkflowReviewInbox).not.toHaveBeenCalled();
|
||||
expect(store.showSidebar).toBe(false);
|
||||
});
|
||||
|
||||
it('refetches when switching tabs', async () => {
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockResolvedValue({
|
||||
data: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
await store.setActiveState('closed');
|
||||
|
||||
expect(store.activeState).toBe('closed');
|
||||
expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ state: 'closed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale list responses', async () => {
|
||||
let resolveFirst!: (value: {
|
||||
data: [];
|
||||
nextCursor: null;
|
||||
hasMore: false;
|
||||
}) => void;
|
||||
const firstResponse = new Promise<{
|
||||
data: [];
|
||||
nextCursor: null;
|
||||
hasMore: false;
|
||||
}>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox)
|
||||
.mockImplementationOnce(async () => await firstResponse)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: 'req-2',
|
||||
projectId: 'proj-1',
|
||||
title: 'Newer',
|
||||
workflowName: null,
|
||||
decision: 'pending',
|
||||
state: 'closed',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
const firstFetch = store.fetchList({ reset: true });
|
||||
await store.setActiveState('closed');
|
||||
await vi.waitFor(() => {
|
||||
expect(store.items).toEqual([expect.objectContaining({ id: 'req-2', title: 'Newer' })]);
|
||||
});
|
||||
|
||||
resolveFirst({ data: [], nextCursor: null, hasMore: false });
|
||||
await firstFetch;
|
||||
|
||||
expect(store.items).toEqual([expect.objectContaining({ id: 'req-2', title: 'Newer' })]);
|
||||
});
|
||||
|
||||
it('does not apply probe results after reset', async () => {
|
||||
let resolveSummary!: (value: { hasAny: boolean }) => void;
|
||||
const summaryPromise = new Promise<{ hasAny: boolean }>((resolve) => {
|
||||
resolveSummary = resolve;
|
||||
});
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockImplementationOnce(
|
||||
async () => await summaryPromise,
|
||||
);
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
const probe = store.probeInbox();
|
||||
store.reset();
|
||||
resolveSummary({ hasAny: true });
|
||||
await probe;
|
||||
|
||||
expect(store.probeSettled).toBe(false);
|
||||
expect(store.hasAnyReviews).toBe(false);
|
||||
expect(workflowReviewsApi.fetchWorkflowReviewInbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears loadingMore when a reset list request invalidates pagination', async () => {
|
||||
let resolveMore!: (value: {
|
||||
data: [];
|
||||
nextCursor: null;
|
||||
hasMore: false;
|
||||
}) => void;
|
||||
const morePromise = new Promise<{
|
||||
data: [];
|
||||
nextCursor: null;
|
||||
hasMore: false;
|
||||
}>((resolve) => {
|
||||
resolveMore = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: 'req-1',
|
||||
projectId: 'proj-1',
|
||||
title: 'Open',
|
||||
workflowName: null,
|
||||
decision: 'pending',
|
||||
state: 'open',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
nextCursor: 'req-1',
|
||||
hasMore: true,
|
||||
})
|
||||
.mockImplementationOnce(async () => await morePromise)
|
||||
.mockResolvedValueOnce({
|
||||
data: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
await store.fetchList({ reset: true });
|
||||
const loadMore = store.loadMore();
|
||||
await vi.waitFor(() => {
|
||||
expect(store.loadingMore).toBe(true);
|
||||
});
|
||||
|
||||
await store.setActiveState('closed');
|
||||
expect(store.loadingMore).toBe(false);
|
||||
|
||||
resolveMore({ data: [], nextCursor: null, hasMore: false });
|
||||
await loadMore;
|
||||
expect(store.loadingMore).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a failed list fetch as an empty inbox', async () => {
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({
|
||||
hasAny: true,
|
||||
});
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockRejectedValue(new Error('network'));
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
await expect(store.probeInbox()).rejects.toThrow('network');
|
||||
|
||||
expect(store.error).toEqual(new Error('network'));
|
||||
expect(store.isEmpty).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type {
|
||||
ListWorkflowReviewInboxResponse,
|
||||
WorkflowReviewRequestState,
|
||||
WorkflowReviewInboxItem,
|
||||
} from '@n8n/api-types';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
import { fetchWorkflowReviewInbox, fetchWorkflowReviewInboxSummary } from './workflowReviews.api';
|
||||
|
||||
const DEFAULT_LIMIT = 15;
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export const useReviewInboxStore = defineStore('workflowReviewInbox', () => {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const probeSettled = ref(false);
|
||||
const hasAnyReviews = ref(false);
|
||||
const items = ref<WorkflowReviewInboxItem[]>([]);
|
||||
const selectedId = ref<string | null>(null);
|
||||
const activeState = ref<WorkflowReviewRequestState>('open');
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
let listRequestSeq = 0;
|
||||
let probeRequestSeq = 0;
|
||||
|
||||
const selectedItem = computed(
|
||||
() => items.value.find((item) => item.id === selectedId.value) ?? null,
|
||||
);
|
||||
const showSidebar = computed(() => probeSettled.value && hasAnyReviews.value);
|
||||
const isEmpty = computed(
|
||||
() => showSidebar.value && !loading.value && error.value === null && items.value.length === 0,
|
||||
);
|
||||
|
||||
function applyListResponse(
|
||||
response: ListWorkflowReviewInboxResponse,
|
||||
{ append }: { append: boolean },
|
||||
): void {
|
||||
items.value = append ? [...items.value, ...response.data] : response.data;
|
||||
nextCursor.value = response.nextCursor;
|
||||
hasMore.value = response.hasMore;
|
||||
}
|
||||
|
||||
async function requestList(cursor?: string): Promise<ListWorkflowReviewInboxResponse> {
|
||||
return await fetchWorkflowReviewInbox(rootStore.restApiContext, {
|
||||
state: activeState.value,
|
||||
limit: DEFAULT_LIMIT,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
async function probeInbox() {
|
||||
const requestSeq = ++probeRequestSeq;
|
||||
probeSettled.value = false;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const summary = await fetchWorkflowReviewInboxSummary(rootStore.restApiContext);
|
||||
if (requestSeq !== probeRequestSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAnyReviews.value = summary.hasAny;
|
||||
probeSettled.value = true;
|
||||
|
||||
if (summary.hasAny) {
|
||||
await fetchList({ reset: true });
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestSeq !== probeRequestSeq) {
|
||||
return;
|
||||
}
|
||||
error.value = toError(e);
|
||||
probeSettled.value = true;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList(options: { reset?: boolean } = {}) {
|
||||
const requestSeq = ++listRequestSeq;
|
||||
|
||||
if (options.reset) {
|
||||
items.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMore.value = false;
|
||||
selectedId.value = null;
|
||||
// Invalidate any in-flight loadMore so pagination is not stuck.
|
||||
loadingMore.value = false;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await requestList();
|
||||
if (requestSeq !== listRequestSeq) {
|
||||
return;
|
||||
}
|
||||
applyListResponse(response, { append: false });
|
||||
} catch (e) {
|
||||
if (requestSeq !== listRequestSeq) {
|
||||
return;
|
||||
}
|
||||
error.value = toError(e);
|
||||
throw e;
|
||||
} finally {
|
||||
if (requestSeq === listRequestSeq) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || loadingMore.value || !hasMore.value || !nextCursor.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSeq = ++listRequestSeq;
|
||||
const cursor = nextCursor.value;
|
||||
loadingMore.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await requestList(cursor);
|
||||
if (requestSeq !== listRequestSeq) {
|
||||
return;
|
||||
}
|
||||
applyListResponse(response, { append: true });
|
||||
} catch (e) {
|
||||
if (requestSeq !== listRequestSeq) {
|
||||
return;
|
||||
}
|
||||
error.value = toError(e);
|
||||
throw e;
|
||||
} finally {
|
||||
if (requestSeq === listRequestSeq) {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveState(state: WorkflowReviewRequestState) {
|
||||
if (activeState.value === state) return;
|
||||
activeState.value = state;
|
||||
await fetchList({ reset: true });
|
||||
}
|
||||
|
||||
function selectItem(id: string) {
|
||||
selectedId.value = id;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedId.value = null;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
probeRequestSeq += 1;
|
||||
listRequestSeq += 1;
|
||||
probeSettled.value = false;
|
||||
hasAnyReviews.value = false;
|
||||
items.value = [];
|
||||
selectedId.value = null;
|
||||
activeState.value = 'open';
|
||||
nextCursor.value = null;
|
||||
hasMore.value = false;
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
probeSettled,
|
||||
hasAnyReviews,
|
||||
items,
|
||||
selectedId,
|
||||
selectedItem,
|
||||
activeState,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
showSidebar,
|
||||
isEmpty,
|
||||
probeInbox,
|
||||
fetchList,
|
||||
loadMore,
|
||||
setActiveState,
|
||||
selectItem,
|
||||
clearSelection,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
|
||||
import { useReviewInboxStore } from '../reviewInbox.store';
|
||||
import WorkflowReviewRequestsView from './WorkflowReviewRequestsView.vue';
|
||||
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: () => ({
|
||||
set: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const showError = vi.fn();
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowReviewRequestsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageViewLayout: {
|
||||
template: '<div data-test-id="workflow-review-requests-view"><slot /></div>',
|
||||
},
|
||||
WorkflowReviewRequestsSidebar: {
|
||||
template: '<div data-test-id="workflow-review-requests-sidebar" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('WorkflowReviewRequestsView', () => {
|
||||
let store: ReturnType<typeof mockedStore<typeof useReviewInboxStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
showError.mockReset();
|
||||
vi.mocked(useToast).mockReturnValue({ showError } as unknown as ReturnType<typeof useToast>);
|
||||
|
||||
store = mockedStore(useReviewInboxStore);
|
||||
store.probeSettled = false;
|
||||
store.showSidebar = false;
|
||||
store.selectedItem = null;
|
||||
store.items = [];
|
||||
store.activeState = 'open';
|
||||
store.selectedId = null;
|
||||
store.loading = false;
|
||||
store.loadingMore = false;
|
||||
store.hasMore = false;
|
||||
store.isEmpty = false;
|
||||
store.probeInbox.mockResolvedValue(undefined);
|
||||
store.reset.mockClear();
|
||||
});
|
||||
|
||||
it('probes the inbox on mount', async () => {
|
||||
renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(store.probeInbox).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows loading while the inbox probe has not settled', async () => {
|
||||
store.probeSettled = false;
|
||||
|
||||
const { container, queryByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-reviews-disclaimer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the disclaimer when settled with nothing selected', async () => {
|
||||
store.probeSettled = true;
|
||||
store.showSidebar = false;
|
||||
store.selectedItem = null;
|
||||
|
||||
const { container, getByTestId, queryByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(getByTestId('workflow-reviews-disclaimer')).toBeInTheDocument();
|
||||
expect(container.querySelector('.n8n-loading')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-requests-sidebar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the sidebar and selected title when a review is selected', async () => {
|
||||
store.probeSettled = true;
|
||||
store.showSidebar = true;
|
||||
store.selectedItem = {
|
||||
id: 'req-1',
|
||||
projectId: 'proj-1',
|
||||
title: 'Needs review',
|
||||
workflowName: 'My workflow',
|
||||
decision: 'pending',
|
||||
state: 'open',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(getByTestId('workflow-review-requests-sidebar')).toBeInTheDocument();
|
||||
expect(getByTestId('workflow-review-request-title')).toHaveTextContent('Needs review');
|
||||
expect(getByTestId('workflow-review-request-detail-stub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error toast when probing the inbox fails', async () => {
|
||||
const error = new Error('network');
|
||||
store.probeInbox.mockRejectedValueOnce(error);
|
||||
|
||||
renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(showError).toHaveBeenCalledWith(error, 'Could not load workflow reviews');
|
||||
});
|
||||
|
||||
it('does not toast probe errors after unmount', async () => {
|
||||
let rejectProbe!: (error: Error) => void;
|
||||
store.probeInbox.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
rejectProbe = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
rejectProbe(new Error('network'));
|
||||
await waitAllPromises();
|
||||
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the store on unmount', async () => {
|
||||
const { unmount } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(store.reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { WorkflowReviewRequestState } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nHeading, N8nLoading, N8nText } from '@n8n/design-system';
|
||||
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
|
||||
import WorkflowReviewRequestsSidebar from '../components/WorkflowReviewRequestsSidebar.vue';
|
||||
import { useReviewInboxStore } from '../reviewInbox.store';
|
||||
|
||||
const store = useReviewInboxStore();
|
||||
const {
|
||||
probeSettled,
|
||||
showSidebar,
|
||||
selectedItem,
|
||||
items,
|
||||
activeState,
|
||||
selectedId,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
isEmpty,
|
||||
} = storeToRefs(store);
|
||||
|
||||
const i18n = useI18n();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const { showError } = useToast();
|
||||
|
||||
documentTitle.set(i18n.baseText('workflowReviews.page.title'));
|
||||
|
||||
let isMounted = false;
|
||||
|
||||
function handleListError(error: unknown) {
|
||||
if (!isMounted) return;
|
||||
showError(error, i18n.baseText('workflowReviews.error.load'));
|
||||
}
|
||||
|
||||
async function onActiveStateChange(state: WorkflowReviewRequestState) {
|
||||
try {
|
||||
await store.setActiveState(state);
|
||||
} catch (error) {
|
||||
await handleListError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function onLoadMore() {
|
||||
try {
|
||||
await store.loadMore();
|
||||
} catch (error) {
|
||||
await handleListError(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isMounted = true;
|
||||
try {
|
||||
await store.probeInbox();
|
||||
} catch (error) {
|
||||
await handleListError(error);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
isMounted = false;
|
||||
store.reset();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout data-test-id="workflow-review-requests-view">
|
||||
<div :class="$style.content">
|
||||
<WorkflowReviewRequestsSidebar
|
||||
v-if="showSidebar"
|
||||
:items="items"
|
||||
:active-state="activeState"
|
||||
:selected-id="selectedId"
|
||||
:loading="loading"
|
||||
:loading-more="loadingMore"
|
||||
:has-more="hasMore"
|
||||
:is-empty="isEmpty"
|
||||
@select="store.selectItem"
|
||||
@clear="store.clearSelection"
|
||||
@update:active-state="onActiveStateChange"
|
||||
@load-more="onLoadMore"
|
||||
/>
|
||||
|
||||
<div :class="$style.main">
|
||||
<div :class="$style.columnTitle">
|
||||
<N8nHeading
|
||||
v-if="showSidebar && selectedItem"
|
||||
bold
|
||||
tag="h2"
|
||||
size="xlarge"
|
||||
data-test-id="workflow-review-request-title"
|
||||
>
|
||||
{{ selectedItem.title }}
|
||||
</N8nHeading>
|
||||
<N8nHeading
|
||||
v-else-if="!showSidebar"
|
||||
bold
|
||||
tag="h2"
|
||||
size="xlarge"
|
||||
data-test-id="workflow-reviews-page-title"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.page.title') }}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
|
||||
<div :class="$style.mainBody">
|
||||
<N8nLoading v-if="!probeSettled" :loading="true" :rows="3" />
|
||||
<N8nText
|
||||
v-else-if="selectedItem"
|
||||
color="text-light"
|
||||
size="medium"
|
||||
data-test-id="workflow-review-request-detail-stub"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.placeholder') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else-if="!showSidebar"
|
||||
color="text-light"
|
||||
size="medium"
|
||||
data-test-id="workflow-reviews-disclaimer"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.disclaimer.body') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else-if="isEmpty"
|
||||
color="text-light"
|
||||
size="medium"
|
||||
data-test-id="workflow-reviews-empty-state"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.emptyState.body') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else
|
||||
color="text-light"
|
||||
size="medium"
|
||||
data-test-id="workflow-reviews-no-selection"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.noSelection.body') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageViewLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 0 var(--spacing--md) var(--spacing--md);
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--spacing--2xl);
|
||||
padding-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.mainBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
GetWorkflowReviewInboxSummaryResponse,
|
||||
ListWorkflowReviewInboxResponse,
|
||||
WorkflowReviewEligibleReviewersList,
|
||||
WorkflowReviewRequestList,
|
||||
WorkflowReviewRequestState,
|
||||
@@ -16,6 +18,13 @@ export interface CreateWorkflowReviewRequestPayload {
|
||||
reviewerUserIds?: string[];
|
||||
}
|
||||
|
||||
export type FetchWorkflowReviewInboxParams = {
|
||||
state?: WorkflowReviewRequestState;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
/** Workflow-scoped list used by review-required toggle sync. */
|
||||
export async function fetchWorkflowReviewRequests(
|
||||
context: IRestApiContext,
|
||||
query: { workflowId: string; state?: WorkflowReviewRequestState; take?: number; skip?: number },
|
||||
@@ -51,3 +60,17 @@ export async function createWorkflowReviewRequest(
|
||||
{ ...payload },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWorkflowReviewInboxSummary(
|
||||
context: IRestApiContext,
|
||||
): Promise<GetWorkflowReviewInboxSummaryResponse> {
|
||||
return await makeRestApiRequest(context, 'GET', '/workflow-review-requests/summary');
|
||||
}
|
||||
|
||||
/** Cross-project inbox list. */
|
||||
export async function fetchWorkflowReviewInbox(
|
||||
context: IRestApiContext,
|
||||
params: FetchWorkflowReviewInboxParams,
|
||||
): Promise<ListWorkflowReviewInboxResponse> {
|
||||
return await makeRestApiRequest(context, 'GET', '/workflow-review-requests/inbox', params);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user