refactor(core): Prepare the shared Inbox (#40739)

Co-authored-by: kgrhartlage <kai.hartlage@n8n.io>
This commit is contained in:
Predrag Ristic 2026-10-16 09:37:52 +00:00 committed by GitHub
parent 2eab299dfa
commit 8bee1eeb20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
131 changed files with 5804 additions and 4436 deletions

1
OWNERS
View File

@ -123,6 +123,7 @@ packages/cli/src/modules/mcp/ @n8n-io/context
packages/modules/quick-connect/ @n8n-io/ai
packages/cli/src/modules/chat-hub/ @n8n-io/ai-assistant
packages/cli/src/modules/instance-ai/ @n8n-io/ai-assistant
packages/modules/inbox/ @n8n-io/ligo
packages/cli/src/modules/community-packages/ @n8n-io/nodes

View File

@ -0,0 +1,33 @@
import { ListInboxQueryDto } from '../list-inbox.dto';
describe('ListInboxQueryDto', () => {
it('defaults to the first Open page', () => {
expect(ListInboxQueryDto.parse({})).toEqual({ state: 'open', limit: 15 });
});
it('accepts query parameters for a Closed continuation page', () => {
expect(ListInboxQueryDto.parse({ state: 'closed', limit: '20', cursor: 'cursor' })).toEqual({
state: 'closed',
limit: 20,
cursor: 'cursor',
});
});
it.each([
{ category: 'other' },
{ category: ['waiting', 'authored'] },
{ state: 'pending' },
{ state: ['open', 'closed'] },
{ limit: 0 },
{ limit: 101 },
{ limit: 1.5 },
{ cursor: '' },
{ cursor: 'a'.repeat(2049) },
])('rejects invalid page input %j', (query) => {
expect(ListInboxQueryDto.safeParse(query).success).toBe(false);
});
it.each(['waiting', 'authored'])('accepts the %s category', (category) => {
expect(ListInboxQueryDto.parse({ category })).toEqual({ state: 'open', limit: 15, category });
});
});

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { inboxCategorySchema, inboxStateSchema } from '../../inbox';
import { Z } from '../../zod-class';
export class ListInboxQueryDto extends Z.class({
state: inboxStateSchema.default('open'),
/** Filters the Open list. Omit for the combined list and the Closed list. */
category: inboxCategorySchema.optional(),
limit: z.coerce.number().int().min(1).max(100).default(15),
cursor: z.string().min(1).max(2048).optional(),
}) {}

View File

@ -651,7 +651,8 @@ export {
type WorkflowReviewRequestList,
} from './workflow-reviews/list-workflow-review-requests-query.dto';
export { UpdateWorkflowReviewRequestVersionDto } from './workflow-reviews/update-workflow-review-request-version.dto';
export * from './workflow-reviews/list-workflow-review-inbox.dto';
export type { WorkflowReviewInboxItem } from './workflow-reviews/list-workflow-review-inbox.dto';
export { ListInboxQueryDto } from './inbox/list-inbox.dto';
export type * from './workflow-reviews/get-workflow-review-request-detail.dto';
export {
ListWorkflowReviewActivityQueryDto,

View File

@ -1,45 +0,0 @@
import { ListWorkflowReviewInboxQueryDto } from '../list-workflow-review-inbox.dto';
describe('ListWorkflowReviewInboxQueryDto', () => {
describe('accepted', () => {
test.each([
{
name: 'no filters at all',
request: {},
parsedResult: { limit: 15 },
},
{
name: 'category waiting',
request: { category: 'waiting' },
parsedResult: { limit: 15, category: 'waiting' },
},
{
name: 'category authored',
request: { category: 'authored' },
parsedResult: { limit: 15, category: 'authored' },
},
{
name: 'category alongside state, limit and cursor',
request: { category: 'authored', state: 'closed', limit: '30', cursor: 'abc' },
parsedResult: { category: 'authored', state: 'closed', limit: 30, cursor: 'abc' },
},
])('accepts $name', ({ request, parsedResult }) => {
const result = ListWorkflowReviewInboxQueryDto.safeParse(request);
expect(result.success).toBe(true);
expect(result.data).toMatchObject(parsedResult);
});
});
describe('rejected', () => {
test.each([
{ name: 'unknown category', request: { category: 'mine' } },
{ name: 'empty category', request: { category: '' } },
{ name: 'boolean-ish category', request: { category: 'true' } },
{ name: 'category as an array', request: { category: ['waiting'] } },
])('rejects $name', ({ request }) => {
const result = ListWorkflowReviewInboxQueryDto.safeParse(request);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['category']);
});
});
});

View File

@ -1,16 +1,7 @@
import { z } from 'zod';
import type { WorkflowReviewEligibleReviewer } from '../../workflow-review-eligible-reviewer';
import {
type WorkflowReviewRequestSummary,
workflowReviewRequestStateSchema,
} from '../../workflow-review-request-summary';
import { Z } from '../../zod-class';
import type { WorkflowReviewRequestSummary } from '../../workflow-review-request-summary';
/**
* Inbox list item — extends {@link WorkflowReviewRequestSummary} with the
* cross-project fields the inbox needs.
*/
/** Review fields used by the Inbox row and detail response. */
export interface WorkflowReviewInboxItem extends WorkflowReviewRequestSummary {
projectId: string;
title: string;
@ -19,46 +10,3 @@ export interface WorkflowReviewInboxItem extends WorkflowReviewRequestSummary {
authors: WorkflowReviewEligibleReviewer[];
reviewers: WorkflowReviewEligibleReviewer[];
}
export const workflowReviewInboxCategorySchema = z.enum(['waiting', 'authored']);
/**
* Which group of reviews to return: `waiting` = the caller is an assigned
* reviewer, or is not an author; `authored` = the caller authored it and is not
* assigned to review it. Being a reviewer wins, so a review sits where the
* caller's pending action is.
*/
export type WorkflowReviewInboxCategory = z.infer<typeof workflowReviewInboxCategorySchema>;
/**
* Inbox query. Defaults to open requests when `state` is omitted.
*/
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(),
/**
* Which group of reviews to return — see {@link WorkflowReviewInboxCategory}.
* Requesting a review and contributing a version both count as authoring it.
* Every visible review falls in exactly one group, so the two add up to the
* unfiltered list.
*
* Independent of `state`: it filters closed reviews the same way. The editor
* sends it only for the open tab, which is why the closed list stays flat.
*
* Omitted means no filter. Narrows visibility, never widens it.
*/
category: workflowReviewInboxCategorySchema.optional(),
}) {}
export interface ListWorkflowReviewInboxResponse {
data: WorkflowReviewInboxItem[];
nextCursor: string | null;
hasMore: boolean;
}
export interface GetWorkflowReviewInboxSummaryResponse {
open: number;
closed: number;
}

View File

@ -6,6 +6,7 @@ import type {
ChatProviderSettingsDto,
} from './chat-hub';
import type { QuickConnectOption } from './quick-connect';
import type { InboxSettings } from './inbox';
import type { InsightsDateRange } from './schemas/insights.schema';
import type { WorkflowReviewsPolicy } from './workflow-reviews-policy';
@ -239,6 +240,7 @@ export interface FrontendSettings {
enforced: boolean;
};
workflowReviews?: WorkflowReviewsPolicy;
inbox?: InboxSettings;
folders: {
enabled: boolean;
};

View File

@ -0,0 +1,45 @@
import { z } from 'zod';
import type { WorkflowReviewInboxItem } from './dto/workflow-reviews/list-workflow-review-inbox.dto';
export const inboxSourceTypeSchema = z.enum(['workflow_review']);
export type InboxSourceType = z.infer<typeof inboxSourceTypeSchema>;
export const inboxStateSchema = z.enum(['open', 'closed']);
export type InboxState = z.infer<typeof inboxStateSchema>;
export const inboxCategorySchema = z.enum(['waiting', 'authored']);
export type InboxCategory = z.infer<typeof inboxCategorySchema>;
export type InboxWorkflowReviewItem = WorkflowReviewInboxItem & {
type: 'workflow_review';
};
export type InboxItem = InboxWorkflowReviewItem;
export type InboxCounts = { open: number; closed: number };
export type InboxSourceStatus = {
/** True when at least one source failed. Disabled sources do not make the response partial. */
partial: boolean;
/** Sources that failed to load. A retry can recover them. */
failedSources: InboxSourceType[];
/** Sources that are unavailable because their feature is disabled or unlicensed. */
disabledSources: InboxSourceType[];
};
export type ListInboxResponse = InboxSourceStatus & {
data: InboxItem[];
hasMore: boolean;
nextCursor: string | null;
};
export type GetInboxSummaryResponse = InboxSourceStatus & {
counts: InboxCounts | null;
};
export type InboxSettings = {
enabled: boolean;
availableTypes: InboxSourceType[];
failedTypes: InboxSourceType[];
};

View File

@ -980,6 +980,7 @@ export { compareExecutionListItems } from './dto/executions/compare-execution-li
export type * from './workflow-suggestions';
export * from './self-healing-results';
export * from './inbox';
export * from './model-priority';
export {

View File

@ -57,14 +57,14 @@ describe('eligibleModules', () => {
expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('policy-infrastructure');
});
it.each(['instance-ai', 'agents', 'agent-evals'])(
it.each(['instance-ai', 'agents', 'agent-evals', 'inbox'])(
'should include %s by default',
(moduleName) => {
expect(Container.get(ModuleRegistry).eligibleModules).toContain(moduleName);
},
);
it.each(['instance-ai', 'agents', 'agent-evals'])(
it.each(['instance-ai', 'agents', 'agent-evals', 'inbox'])(
'should allow opting out of the default %s module via env var',
(moduleName) => {
process.env.N8N_DISABLED_MODULES = moduleName;
@ -105,6 +105,7 @@ describe('eligibleModules', () => {
'runtime-credentials',
'mcp-registry',
'workflow-reviews',
'inbox',
'instance-ai',
'agents',
'agent-evals',
@ -146,6 +147,7 @@ describe('eligibleModules', () => {
'runtime-credentials',
'mcp-registry',
'workflow-reviews',
'inbox',
'instance-ai',
'agents',
'agent-evals',

View File

@ -81,6 +81,7 @@ export class ModuleRegistry {
'runtime-credentials',
'mcp-registry',
'workflow-reviews',
'inbox',
'instance-ai',
'agents',
'agent-evals',

View File

@ -3,6 +3,7 @@ import { CommaSeparatedStringArray, Config, Env } from '@n8n/config';
import { UnknownModuleError } from './errors/unknown-module.error';
export const MODULE_NAMES = [
'inbox',
'agents',
'agent-evals',
'insights',

View File

@ -614,7 +614,7 @@ describe('workflow review repositories', () => {
await inboxRepository.findRequests({
visibility: involvedVisibility(),
limit: 10,
cursor: { createdAt, id: 'req-cursor' },
boundary: { mode: 'afterItem', createdAt, id: 'req-cursor' },
});
expect(findOneSpy).not.toHaveBeenCalled();
@ -624,6 +624,17 @@ describe('workflow review repositories', () => {
);
});
it('uses older timestamps for a boundary from another source', async () => {
const createdAt = new Date('2026-10-07T00:00:00.000Z');
await inboxRepository.findRequests({
visibility: allVisibility,
limit: 10,
boundary: { mode: 'beforeTime', createdAt },
});
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.createdAt < :createdAt', {
createdAt,
});
});
describe('category filter', () => {
it('leaves the query untouched when no category is requested', async () => {
await inboxRepository.findRequests({
@ -704,7 +715,7 @@ describe('workflow review repositories', () => {
category: { userId: 'user-1', category: 'authored' },
state: 'open',
limit: 15,
cursor: { createdAt, id: 'req-cursor' },
boundary: { mode: 'afterItem', createdAt, id: 'req-cursor' },
});
const categoryCall = queryBuilder.andWhere.mock.invocationCallOrder[0];

View File

@ -127,7 +127,7 @@ export {
export { WorkflowReviewLifecycleRepository } from './workflow-review-lifecycle.repository';
export {
WorkflowReviewInboxRepository,
type InboxCursor,
type WorkflowReviewInboxBoundary,
type InboxVisibility,
} from './workflow-review-inbox.repository';
export {

View File

@ -12,18 +12,13 @@ import {
} from '../entities/workflow-review-request.ee';
import { TransactionRunner } from '../services/transaction';
/** The cursor carries its boundary values so deleting the previous page's last row is safe. */
export type InboxCursor = {
createdAt: Date;
id: string;
};
export type WorkflowReviewInboxBoundary =
| { mode: 'beforeTime'; createdAt: Date }
| { mode: 'afterItem'; createdAt: Date; id: string };
/**
* Reviewers belong in `waiting`, even when they are also authors. All other authors belong in
* `authored`. The requester always has an author row, so this does not use `createdById`.
*/
type InboxCategoryFilter = {
userId: string;
// Assigned reviewers belong in waiting, even when they also authored the review.
category: 'waiting' | 'authored';
};
@ -43,7 +38,7 @@ type FindInboxRequestsOptions = {
state?: WorkflowReviewRequestState;
category?: InboxCategoryFilter;
limit: number;
cursor?: InboxCursor;
boundary?: WorkflowReviewInboxBoundary;
};
export type InboxStateCounts = {
@ -183,7 +178,7 @@ export class WorkflowReviewInboxRepository extends BaseRepository<WorkflowReview
}
async findRequests(options: FindInboxRequestsOptions): Promise<WorkflowReviewRequest[]> {
const { visibility, state, category, limit, cursor } = options;
const { visibility, state, category, limit, boundary } = options;
const queryBuilder = this.createQueryBuilder('review')
.orderBy('review.createdAt', 'DESC')
.addOrderBy('review.id', 'ASC');
@ -193,11 +188,13 @@ export class WorkflowReviewInboxRepository extends BaseRepository<WorkflowReview
if (state !== undefined) {
queryBuilder.andWhere('review.state = :state', { state });
}
if (cursor) {
if (boundary?.mode === 'afterItem') {
queryBuilder.andWhere(
'(review.createdAt < :createdAt OR (review.createdAt = :createdAt AND review.id > :id))',
{ createdAt: cursor.createdAt, id: cursor.id },
{ createdAt: boundary.createdAt, id: boundary.id },
);
} else if (boundary) {
queryBuilder.andWhere('review.createdAt < :createdAt', { createdAt: boundary.createdAt });
}
queryBuilder.take(limit);

View File

@ -0,0 +1,17 @@
import { UnexpectedError } from '@n8n/errors';
import { mock } from 'vitest-mock-extended';
import { InboxSourceRegistry, type InboxSource } from '../inbox-source.registry';
describe('InboxSourceRegistry', () => {
it('rejects a duplicate without replacing the registered source', () => {
const registry = new InboxSourceRegistry();
const source = mock<InboxSource>({ type: 'workflow_review' });
registry.register(source);
expect(() => registry.register(mock<InboxSource>({ type: 'workflow_review' }))).toThrow(
UnexpectedError,
);
expect(registry.find('workflow_review')).toBe(source);
expect(registry.types()).toEqual(['workflow_review']);
});
});

View File

@ -0,0 +1,37 @@
import type { AuthenticatedRequest } from '@n8n/db';
import type { Response } from 'express';
import { mock } from 'vitest-mock-extended';
import { getRouteCases } from '@test/controller-route-metadata';
import { InboxController } from '../inbox.controller';
import type { InboxService } from '../inbox.service';
describe('InboxController', () => {
const routes = getRouteCases(InboxController);
it('exposes only the shared list and summary reads', () => {
expect(routes.map(({ handlerName }) => handlerName).sort()).toEqual(['getSummary', 'list']);
});
// Cross-project reads use each source's service authorization.
it.each(routes)(
'$handlerName requires authentication without a global workflow gate',
({ route }) => {
expect(route.skipAuth).toBe(false);
expect(route.accessScope).toBeUndefined();
expect(route.licenseFeature).toBeUndefined();
},
);
it('passes the authenticated user and validated query to the service', async () => {
const service = mock<InboxService>();
const controller = new InboxController(service);
const request = mock<AuthenticatedRequest>();
const query = { state: 'closed' as const, limit: 10 };
await controller.list(request, mock<Response>(), query);
await controller.getSummary(request);
expect(service.list).toHaveBeenCalledWith(request.user, query);
expect(service.getSummary).toHaveBeenCalledWith(request.user);
});
});

View File

@ -0,0 +1,354 @@
import type { InboxItem, InboxSourceType, InboxWorkflowReviewItem } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { BadRequestError, ServiceUnavailableError } from '@n8n/errors';
import { mock } from 'vitest-mock-extended';
import { InboxSourceRegistry, type InboxSourceQuery } from '../inbox-source.registry';
import { InboxService } from '../inbox.service';
const timestamp = (hour: number) => new Date(Date.UTC(2026, 9, 7, hour)).toISOString();
function review(id: string, hour: number): InboxWorkflowReviewItem {
return {
type: 'workflow_review',
id,
state: 'open',
decision: 'pending',
projectId: 'project',
title: id,
workflowName: 'Workflow',
workflowVersionId: null,
requester: null,
authors: [],
reviewers: [],
createdAt: timestamp(hour),
updatedAt: timestamp(hour),
};
}
function createSource(type: InboxSourceType, rows: InboxItem[] = []) {
return {
type,
isEnabled: vi.fn(async () => true),
list: vi.fn(async (_user: User, { state, boundary, limit }: InboxSourceQuery) =>
rows
.filter((item) => {
if (item.state !== state) return false;
if (!boundary) return true;
const difference = Date.parse(item.createdAt) - boundary.createdAt.getTime();
return (
difference < 0 ||
(difference === 0 && boundary.mode === 'afterItem' && item.id > boundary.id)
);
})
.slice(0, limit),
),
count: vi.fn(async (_user: User) => ({
open: rows.filter(({ state }) => state === 'open').length,
closed: rows.filter(({ state }) => state === 'closed').length,
})),
};
}
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
describe('InboxService', () => {
const user = mock<User>({ id: 'user' });
let registry: InboxSourceRegistry;
let logger: ReturnType<typeof mock<Logger>>;
let service: InboxService;
beforeEach(() => {
registry = new InboxSourceRegistry();
logger = mock<Logger>();
service = new InboxService(registry, logger);
});
it('pages through a source without skipping prefetched rows', async () => {
const reviews = createSource('workflow_review', [
review('r12', 12),
review('r11', 11),
review('r10', 10),
review('r9', 9),
review('r8', 8),
review('r7', 7),
]);
registry.register(reviews);
const first = await service.list(user, { state: 'open', limit: 2 });
const second = await service.list(user, { state: 'open', limit: 2, cursor: first.nextCursor! });
const third = await service.list(user, { state: 'open', limit: 2, cursor: second.nextCursor! });
expect(first.data.map(({ id }) => id)).toEqual(['r12', 'r11']);
expect(second.data.map(({ id }) => id)).toEqual(['r10', 'r9']);
expect(third.data.map(({ id }) => id)).toEqual(['r8', 'r7']);
expect(third).toMatchObject({ hasMore: false, nextCursor: null, partial: false });
expect(reviews.list).toHaveBeenNthCalledWith(2, user, {
state: 'open',
limit: 3,
boundary: { mode: 'afterItem', createdAt: new Date(timestamp(11)), id: 'r11' },
});
expect(reviews.count).not.toHaveBeenCalled();
});
it.each(['waiting', 'authored'] as const)(
'passes the %s category to Closed source reads',
async (category) => {
const source = createSource('workflow_review');
registry.register(source);
await service.list(user, { state: 'closed', category, limit: 15 });
expect(source.list).toHaveBeenCalledWith(
user,
expect.objectContaining({ state: 'closed', category }),
);
},
);
it('rejects cursors used with another category', async () => {
const source = createSource('workflow_review');
registry.register(source);
const cursor = {
version: 1,
state: 'open',
category: 'authored',
after: { type: 'workflow_review', id: 'r12', createdAt: timestamp(12) },
activeSources: ['workflow_review'],
failedSources: [],
};
for (const category of [undefined, 'waiting'] as const) {
await expect(
service.list(user, { state: 'open', category, limit: 15, cursor: encode(cursor) }),
).rejects.toBeInstanceOf(BadRequestError);
}
await expect(
service.list(user, {
state: 'open',
category: 'waiting',
limit: 15,
cursor: encode({ ...cursor, category: undefined }),
}),
).rejects.toBeInstanceOf(BadRequestError);
expect(source.isEnabled).not.toHaveBeenCalled();
});
it.each([1, 3])(
'continues tied timestamps within the cursor source with page size %s',
async (limit) => {
registry.register(createSource('workflow_review', [review('1', 12), review('2', 12)]));
const keys: string[] = [];
let cursor: string | undefined;
do {
const page = await service.list(user, { state: 'open', limit, cursor });
keys.push(...page.data.map(({ type, id }) => `${type}:${id}`));
cursor = page.nextCursor ?? undefined;
} while (cursor);
expect(keys).toEqual(['workflow_review:1', 'workflow_review:2']);
},
);
it('preserves the source database order when IDs use a different collation', async () => {
registry.register(createSource('workflow_review', [review('a', 12), review('B', 12)]));
const page = await service.list(user, { state: 'open', limit: 3 });
expect(page.data.map(({ type, id }) => `${type}:${id}`)).toEqual([
'workflow_review:a',
'workflow_review:B',
]);
});
it('continues after the anchor row is deleted', async () => {
const rows = [review('r12', 12), review('r10', 10), review('r8', 8)];
registry.register(createSource('workflow_review', rows));
const first = await service.list(user, { state: 'open', limit: 1 });
rows.shift();
const next = await service.list(user, { state: 'open', limit: 1, cursor: first.nextCursor! });
expect(next.data.map(({ id }) => id)).toEqual(['r10']);
});
it('passes the current user and tab to each source', async () => {
const closed = { ...review('closed', 11), state: 'closed' as const };
const source = createSource('workflow_review', [review('open', 12), closed]);
registry.register(source);
const page = await service.list(user, { state: 'closed', limit: 15 });
expect(page.data).toEqual([closed]);
expect(source.list).toHaveBeenCalledWith(user, {
state: 'closed',
limit: 16,
boundary: undefined,
});
});
it('returns a retryable error with source metadata when all reads fail', async () => {
for (const type of ['workflow_review'] as const) {
const source = createSource(type);
source.list.mockRejectedValue(new Error('Private query details'));
registry.register(source);
}
const request = service.list(user, { state: 'open', limit: 15 });
await expect(request).rejects.toBeInstanceOf(ServiceUnavailableError);
await expect(request).rejects.toMatchObject({
httpStatusCode: 503,
message: 'Inbox is temporarily unavailable',
meta: {
partial: true,
failedSources: ['workflow_review'],
disabledSources: [],
},
});
expect(logger.warn).toHaveBeenCalledTimes(1);
});
it('reports a disabled source without reading it', async () => {
const source = createSource('workflow_review', [review('r12', 12)]);
source.isEnabled.mockResolvedValue(false);
registry.register(source);
await expect(service.list(user, { state: 'open', limit: 15 })).resolves.toEqual({
data: [],
hasMore: false,
nextCursor: null,
partial: false,
failedSources: [],
disabledSources: ['workflow_review'],
});
expect(source.list).not.toHaveBeenCalled();
});
it('checks current availability on later pages and removes a disabled source', async () => {
const reviews = createSource('workflow_review', [review('r12', 12), review('r10', 10)]);
registry.register(reviews);
const first = await service.list(user, { state: 'open', limit: 1 });
reviews.isEnabled.mockResolvedValue(false);
const second = await service.list(user, { state: 'open', limit: 1, cursor: first.nextCursor! });
expect(second.data.map(({ id }) => id)).toEqual([]);
expect(second.disabledSources).toEqual(['workflow_review']);
expect(reviews.list).toHaveBeenCalledTimes(1);
});
it('treats a recognized source absent after restart as disabled', async () => {
const cursor = encode({
version: 1,
state: 'open',
after: { type: 'workflow_review', id: 'r11', createdAt: timestamp(11) },
activeSources: ['workflow_review'],
failedSources: [],
});
const page = await service.list(user, { state: 'open', limit: 15, cursor });
expect(page.data.map(({ id }) => id)).toEqual([]);
expect(page.disabledSources).toEqual(['workflow_review']);
});
describe('cursor validation', () => {
const validCursor = {
version: 1,
state: 'open',
after: { type: 'workflow_review', id: 'row', createdAt: timestamp(12) },
activeSources: ['workflow_review'],
failedSources: [],
};
it.each([
['invalid encoding', '!!!'],
['invalid JSON', Buffer.from('not JSON').toString('base64url')],
['large cursor', 'x'.repeat(2049)],
['wrong version', encode({ ...validCursor, version: 2 })],
['wrong tab', encode({ ...validCursor, state: 'closed' })],
['unknown source', encode({ ...validCursor, activeSources: ['unknown'] })],
[
'duplicate source',
encode({ ...validCursor, activeSources: ['workflow_review', 'workflow_review'] }),
],
['overlapping source', encode({ ...validCursor, failedSources: ['workflow_review'] })],
['empty source set', encode({ ...validCursor, activeSources: [] })],
['empty anchor ID', encode({ ...validCursor, after: { ...validCursor.after, id: '' } })],
[
'invalid timestamp',
encode({ ...validCursor, after: { ...validCursor.after, createdAt: 'yesterday' } }),
],
])('rejects %s before reading sources', async (_name, cursor) => {
const source = createSource('workflow_review');
registry.register(source);
await expect(service.list(user, { state: 'open', limit: 15, cursor })).rejects.toBeInstanceOf(
BadRequestError,
);
expect(source.isEnabled).not.toHaveBeenCalled();
});
});
describe('summary', () => {
it('adds authorized counts without listing rows', async () => {
const reviews = createSource('workflow_review', [
review('r12', 12),
{ ...review('r11', 11), state: 'closed' },
]);
registry.register(reviews);
await expect(service.getSummary(user)).resolves.toEqual({
counts: { open: 1, closed: 1 },
partial: false,
failedSources: [],
disabledSources: [],
});
expect(reviews.count).toHaveBeenCalledWith(user);
expect(reviews.list).not.toHaveBeenCalled();
});
it('returns an error when every count fails', async () => {
const source = createSource('workflow_review');
source.count.mockRejectedValue(new Error('Count failed'));
registry.register(source);
await expect(service.getSummary(user)).rejects.toBeInstanceOf(ServiceUnavailableError);
});
});
describe('settings', () => {
it('keeps an available empty Inbox visible without reading rows or counts', async () => {
const source = createSource('workflow_review');
registry.register(source);
await expect(service.getSettings()).resolves.toEqual({
enabled: true,
availableTypes: ['workflow_review'],
failedTypes: [],
});
expect(source.list).not.toHaveBeenCalled();
expect(source.count).not.toHaveBeenCalled();
});
it('reflects changes to an already registered source', async () => {
const source = createSource('workflow_review');
registry.register(source);
source.isEnabled.mockResolvedValue(false);
await expect(service.getSettings()).resolves.toEqual({
enabled: false,
availableTypes: [],
failedTypes: [],
});
source.isEnabled.mockResolvedValue(true);
await expect(service.getSettings()).resolves.toEqual({
enabled: true,
availableTypes: ['workflow_review'],
failedTypes: [],
});
});
it('keeps a retry path when availability is unknown', async () => {
const source = createSource('workflow_review');
source.isEnabled.mockRejectedValue(new Error('Availability failed'));
registry.register(source);
await expect(service.getSettings()).resolves.toEqual({
enabled: true,
availableTypes: [],
failedTypes: ['workflow_review'],
});
});
it('disables Inbox when no sources are registered', async () => {
await expect(service.getSettings()).resolves.toEqual({
enabled: false,
availableTypes: [],
failedTypes: [],
});
await expect(service.getSummary(user)).resolves.toMatchObject({
counts: { open: 0, closed: 0 },
partial: false,
});
});
});
});

View File

@ -0,0 +1,56 @@
import type {
InboxCategory,
InboxCounts,
InboxItem,
InboxSourceType,
InboxState,
} from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { UnexpectedError } from '@n8n/errors';
export type InboxSourceBoundary =
// Include only earlier timestamps.
| { mode: 'beforeTime'; createdAt: Date }
// Include earlier timestamps, or equal timestamps with a greater ID in database order.
| { mode: 'afterItem'; createdAt: Date; id: string };
export type InboxSourceQuery = {
state: InboxState;
category?: InboxCategory;
limit: number;
boundary?: InboxSourceBoundary;
};
export interface InboxSource {
type: InboxSourceType;
// Return false when configuration or licensing excludes the source. Reject if the check fails.
isEnabled(): Promise<boolean>;
// Apply access, state, and category filters before the boundary and limit.
// Return createdAt DESC, then id ASC in database order.
list(user: User, query: InboxSourceQuery): Promise<InboxItem[]>;
// Use the same access and state rules as list, without a boundary or limit.
count(user: User): Promise<InboxCounts>;
}
@Service()
export class InboxSourceRegistry {
private readonly sources = new Map<InboxSourceType, InboxSource>();
register(source: InboxSource) {
if (this.sources.has(source.type)) {
throw new UnexpectedError('Inbox source is already registered', {
extra: { type: source.type },
});
}
this.sources.set(source.type, source);
}
find(type: InboxSourceType) {
return this.sources.get(type);
}
types() {
return [...this.sources.keys()];
}
}

View File

@ -0,0 +1,22 @@
import { ListInboxQueryDto } from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Get, Query, RestController } from '@n8n/decorators';
import type { Response } from 'express';
import { InboxService } from './inbox.service';
@RestController('/inbox')
export class InboxController {
constructor(private readonly inboxService: InboxService) {}
// Each source enforces access. A global workflow scope would exclude project members.
@Get('/')
async list(req: AuthenticatedRequest, _res: Response, @Query query: ListInboxQueryDto) {
return await this.inboxService.list(req.user, query);
}
@Get('/summary')
async getSummary(req: AuthenticatedRequest) {
return await this.inboxService.getSummary(req.user);
}
}

View File

@ -0,0 +1,8 @@
import { BackendModule, type ModuleInterface } from '@n8n/decorators';
@BackendModule({ name: 'inbox', instanceTypes: ['main'] })
export class InboxModule implements ModuleInterface {
async init() {
await import('./inbox.controller.js');
}
}

View File

@ -0,0 +1,241 @@
import {
inboxCategorySchema,
inboxSourceTypeSchema,
inboxStateSchema,
type GetInboxSummaryResponse,
type InboxCategory,
type InboxItem,
type InboxSettings,
type InboxSourceStatus,
type InboxSourceType,
type InboxState,
type ListInboxQueryDto,
type ListInboxResponse,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { BadRequestError, ServiceUnavailableError } from '@n8n/errors';
import { z } from 'zod';
import {
InboxSourceRegistry,
type InboxSource,
type InboxSourceBoundary,
} from './inbox-source.registry';
const sourceOrder: InboxSourceType[] = ['workflow_review'];
const cursorSchema = z
.object({
version: z.literal(1),
state: inboxStateSchema,
category: inboxCategorySchema.optional(),
after: z.object({
createdAt: z.string().datetime(),
type: inboxSourceTypeSchema,
id: z.string().min(1).max(128),
}),
activeSources: z.array(inboxSourceTypeSchema).min(1).max(sourceOrder.length),
failedSources: z.array(inboxSourceTypeSchema).max(sourceOrder.length),
})
.strict();
type InboxCursor = z.infer<typeof cursorSchema>;
type SourceReads<T> = {
successful: Array<{ type: InboxSourceType; value: T }>;
failedSources: InboxSourceType[];
disabledSources: InboxSourceType[];
};
class InboxUnavailableError extends ServiceUnavailableError {
override readonly meta: Record<string, unknown>;
constructor(status: InboxSourceStatus) {
super('Inbox is temporarily unavailable');
this.meta = { ...status };
}
}
@Service()
export class InboxService {
constructor(
private readonly registry: InboxSourceRegistry,
private readonly logger: Logger,
) {}
async list(user: User, query: ListInboxQueryDto): Promise<ListInboxResponse> {
const sourceTypes = this.sourcesForCategory(query.category);
const cursor = query.cursor
? this.decodeCursor(query.cursor, query.state, query.category, sourceTypes)
: undefined;
const reads = await this.readAvailableSources(
cursor?.activeSources ?? this.registry.types().filter((type) => sourceTypes.includes(type)),
async (source) =>
await source.list(user, {
state: query.state,
...(query.category !== undefined ? { category: query.category } : {}),
limit: query.limit + 1,
boundary: this.boundaryFor(source.type, cursor?.after),
}),
);
const failedSources = [...new Set([...(cursor?.failedSources ?? []), ...reads.failedSources])];
const status: InboxSourceStatus = {
partial: failedSources.length > 0,
failedSources,
disabledSources: reads.disabledSources,
};
if (reads.successful.length === 0 && status.partial) {
throw new InboxUnavailableError(status);
}
const rows = this.mergeRows(
reads.successful.map(({ value }) => value),
query.limit + 1,
);
const data = rows.slice(0, query.limit);
const hasMore = rows.length > query.limit;
const last = data.at(-1);
return {
...status,
data,
hasMore,
nextCursor:
hasMore && last
? this.encodeCursor({
version: 1,
state: query.state,
...(query.category !== undefined ? { category: query.category } : {}),
after: { createdAt: last.createdAt, type: last.type, id: last.id },
activeSources: reads.successful.map(({ type }) => type),
failedSources,
})
: null,
};
}
async getSummary(user: User): Promise<GetInboxSummaryResponse> {
const reads = await this.readAvailableSources(
this.registry.types(),
async (source) => await source.count(user),
);
const status: InboxSourceStatus = {
partial: reads.failedSources.length > 0,
failedSources: reads.failedSources,
disabledSources: reads.disabledSources,
};
if (reads.successful.length === 0 && status.partial) {
throw new InboxUnavailableError(status);
}
return {
...status,
counts: status.partial
? null
: reads.successful.reduce(
(counts, { value }) => ({
open: counts.open + value.open,
closed: counts.closed + value.closed,
}),
{ open: 0, closed: 0 },
),
};
}
async getSettings(): Promise<InboxSettings> {
const reads = await this.readAvailableSources(this.registry.types(), async () => true);
return {
enabled: reads.successful.length > 0 || reads.failedSources.length > 0,
availableTypes: reads.successful.map(({ type }) => type),
failedTypes: reads.failedSources,
};
}
private async readAvailableSources<T>(
types: InboxSourceType[],
operation: (source: InboxSource) => Promise<T>,
): Promise<SourceReads<T>> {
const settled = await Promise.allSettled(
types.map(async (type): Promise<{ disabled: true } | { disabled: false; value: T }> => {
const source = this.registry.find(type);
if (!source || !(await source.isEnabled())) return { disabled: true };
return { disabled: false, value: await operation(source) };
}),
);
const reads: SourceReads<T> = { successful: [], failedSources: [], disabledSources: [] };
for (const [index, type] of types.entries()) {
const result = settled[index];
if (result.status === 'rejected') {
this.logger.warn('Inbox source read failed', { type, error: result.reason });
reads.failedSources.push(type);
} else if (result.value.disabled) {
reads.disabledSources.push(type);
} else {
reads.successful.push({ type, value: result.value.value });
}
}
return reads;
}
private boundaryFor(
type: InboxSourceType,
after: InboxCursor['after'] | undefined,
): InboxSourceBoundary | undefined {
if (!after) return undefined;
const createdAt = new Date(after.createdAt);
if (type === after.type) return { mode: 'afterItem', createdAt, id: after.id };
// Other sources resume at older timestamps. Equal timestamps can be skipped.
return { mode: 'beforeTime', createdAt };
}
private mergeRows(pages: InboxItem[][], limit: number): InboxItem[] {
// Stable sorting preserves each source's database order for equal timestamps.
return pages
.flat()
.sort(
(left, right) =>
Date.parse(right.createdAt) - Date.parse(left.createdAt) ||
sourceOrder.indexOf(left.type) - sourceOrder.indexOf(right.type),
)
.slice(0, limit);
}
private encodeCursor(cursor: InboxCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}
private sourcesForCategory(category?: InboxCategory): InboxSourceType[] {
return category === 'authored' ? ['workflow_review'] : sourceOrder;
}
private decodeCursor(
value: string,
state: InboxState,
category: InboxCategory | undefined,
sourceTypes: InboxSourceType[],
): InboxCursor {
try {
if (value.length > 2048 || !/^[\w-]+$/.test(value)) {
throw new BadRequestError('Invalid Inbox pagination cursor');
}
const cursor = cursorSchema.parse(
JSON.parse(Buffer.from(value, 'base64url').toString('utf8')),
);
const sources = [...cursor.activeSources, ...cursor.failedSources];
if (
cursor.state !== state ||
cursor.category !== category ||
sources.some((type) => !sourceTypes.includes(type)) ||
new Set(sources).size !== sources.length ||
!cursor.activeSources.includes(cursor.after.type)
) {
throw new BadRequestError('Invalid Inbox pagination cursor');
}
return cursor;
} catch {
throw new BadRequestError('Invalid Inbox pagination cursor');
}
}
}

View File

@ -34,7 +34,7 @@ const workflowValidationService = mockInstance(WorkflowValidationService);
const testServer = utils.setupTestServer({
endpointGroups: ['workflow-reviews', 'workflows'],
enabledFeatures: ['feat:workflowReviews'],
modules: ['workflow-reviews'],
modules: ['workflow-reviews', 'inbox'],
});
let owner: User;
@ -485,8 +485,8 @@ describe('GET /workflow-review-requests/:workflowReviewRequestId', () => {
const workflow = await createWorkflow({}, teamProject);
await seedRequest(workflow.id, null, owner);
await ownerAgent.get('/workflow-review-requests/inbox').expect(200);
await ownerAgent.get('/workflow-review-requests/summary').expect(200);
await ownerAgent.get('/inbox').expect(200);
await ownerAgent.get('/inbox/summary').expect(200);
// 400 (missing workflowId), not 404 — proves it still reaches its own handler
await ownerAgent.get('/workflow-review-requests/eligible-reviewers').expect(400);
});

View File

@ -39,7 +39,7 @@ const workflowValidationService = mockInstance(WorkflowValidationService);
const testServer = utils.setupTestServer({
endpointGroups: ['workflow-reviews', 'workflows'],
enabledFeatures: ['feat:workflowReviews'],
modules: ['workflow-reviews'],
modules: ['workflow-reviews', 'inbox'],
});
let owner: User;
@ -145,29 +145,29 @@ async function seedOrphanedOpenReview() {
return orphan;
}
describe('GET /workflow-review-requests/summary', () => {
describe('GET /inbox/summary', () => {
test('counts open and closed reviews for the instance owner', async () => {
await seedInboxRequests();
const response = await ownerAgent.get('/workflow-review-requests/summary').expect(200);
const response = await ownerAgent.get('/inbox/summary').expect(200);
expect(response.body.data).toEqual({ open: 1, closed: 1 });
expect(response.body.data.counts).toEqual({ open: 1, closed: 1 });
});
test('counts the reviews an assigned reviewer was asked to look at', async () => {
await seedInboxRequests();
const response = await memberAgent.get('/workflow-review-requests/summary').expect(200);
const response = await memberAgent.get('/inbox/summary').expect(200);
expect(response.body.data).toEqual({ open: 1, closed: 1 });
expect(response.body.data.counts).toEqual({ open: 1, closed: 1 });
});
test('counts nothing for an uninvolved project member', async () => {
await seedInboxRequests();
const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200);
const response = await viewerAgent.get('/inbox/summary').expect(200);
expect(response.body.data).toEqual({ open: 0, closed: 0 });
expect(response.body.data.counts).toEqual({ open: 0, closed: 0 });
});
test('counts a requester their own review even without publish scope', async () => {
@ -178,9 +178,9 @@ describe('GET /workflow-review-requests/summary', () => {
});
await linkToNewWorkflow(ownRequest.id);
const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200);
const response = await viewerAgent.get('/inbox/summary').expect(200);
expect(response.body.data).toEqual({ open: 1, closed: 0 });
expect(response.body.data.counts).toEqual({ open: 1, closed: 0 });
});
test('still counts an open review orphaned by a workflow hard delete until a sweep closes it', async () => {
@ -188,28 +188,29 @@ describe('GET /workflow-review-requests/summary', () => {
await seedOrphanedOpenReview();
// Owner exercises the whole-inbox scope, member the involvement filter
const ownerResponse = await ownerAgent.get('/workflow-review-requests/summary').expect(200);
expect(ownerResponse.body.data).toEqual({ open: 2, closed: 1 });
const ownerResponse = await ownerAgent.get('/inbox/summary').expect(200);
expect(ownerResponse.body.data.counts).toEqual({ open: 2, closed: 1 });
const memberResponse = await memberAgent.get('/workflow-review-requests/summary').expect(200);
expect(memberResponse.body.data).toEqual({ open: 2, closed: 1 });
const memberResponse = await memberAgent.get('/inbox/summary').expect(200);
expect(memberResponse.body.data.counts).toEqual({ open: 2, closed: 1 });
});
test('refuses the counts once an admin turns reviews off', async () => {
test('omits review counts once an admin turns reviews off', async () => {
await policyService.set(false);
await ownerAgent.get('/workflow-review-requests/summary').expect(403);
const response = await ownerAgent.get('/inbox/summary').expect(200);
expect(response.body.data).toMatchObject({
counts: { open: 0, closed: 0 },
disabledSources: ['workflow_review'],
});
});
});
describe('GET /workflow-review-requests/inbox', () => {
describe('GET /inbox', () => {
test('shows the instance owner every open review', async () => {
const { openRequest, openWorkflow } = await seedInboxRequests();
const response = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 15 })
.expect(200);
const response = await ownerAgent.get('/inbox').query({ state: 'open', limit: 15 }).expect(200);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0]).toMatchObject({
@ -226,7 +227,7 @@ describe('GET /workflow-review-requests/inbox', () => {
test('shows nothing to an uninvolved project member', async () => {
await seedInboxRequests();
const response = await viewerAgent.get('/workflow-review-requests/inbox').expect(200);
const response = await viewerAgent.get('/inbox').expect(200);
expect(response.body.data.data).toEqual([]);
expect(response.body.data.hasMore).toBe(false);
@ -238,7 +239,7 @@ describe('GET /workflow-review-requests/inbox', () => {
// Owner exercises the whole-inbox scope, member the involvement filter
const ownerResponse = await ownerAgent
.get('/workflow-review-requests/inbox')
.get('/inbox')
.query({ state: 'open', limit: 15 })
.expect(200);
expect(ownerResponse.body.data.data.map((row: { id: string }) => row.id).sort()).toEqual(
@ -246,7 +247,7 @@ describe('GET /workflow-review-requests/inbox', () => {
);
const memberResponse = await memberAgent
.get('/workflow-review-requests/inbox')
.get('/inbox')
.query({ state: 'open', limit: 15 })
.expect(200);
expect(memberResponse.body.data.data.map((row: { id: string }) => row.id).sort()).toEqual(
@ -258,7 +259,7 @@ describe('GET /workflow-review-requests/inbox', () => {
const { closedRequest } = await seedInboxRequests();
const response = await ownerAgent
.get('/workflow-review-requests/inbox')
.get('/inbox')
.query({ state: 'closed', limit: 15 })
.expect(200);
@ -268,10 +269,11 @@ describe('GET /workflow-review-requests/inbox', () => {
]);
});
test('refuses everything once an admin turns reviews off', async () => {
test('omits review rows once an admin turns reviews off', async () => {
await policyService.set(false);
await ownerAgent.get('/workflow-review-requests/inbox').expect(403);
const response = await ownerAgent.get('/inbox').expect(200);
expect(response.body.data).toMatchObject({ data: [], disabledSources: ['workflow_review'] });
});
test('pages through the inbox with a cursor', async () => {
@ -287,17 +289,14 @@ describe('GET /workflow-review-requests/inbox', () => {
);
await linkToNewWorkflow(secondRequest.id);
const firstPage = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 1 })
.expect(200);
const firstPage = await ownerAgent.get('/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')
.get('/inbox')
.query({
state: 'open',
limit: 1,
@ -327,10 +326,10 @@ describe('GET /workflow-review-requests/inbox', () => {
{},
);
const memberResponse = await memberAgent.get('/workflow-review-requests/inbox').expect(200);
const memberResponse = await memberAgent.get('/inbox').expect(200);
expect(memberResponse.body.data.data).toEqual([]);
const ownerResponse = await ownerAgent.get('/workflow-review-requests/inbox').expect(200);
const ownerResponse = await ownerAgent.get('/inbox').expect(200);
expect(ownerResponse.body.data.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
@ -353,7 +352,7 @@ describe('GET /workflow-review-requests/inbox', () => {
);
await linkToNewWorkflow(ownRequest.id, otherProject);
const response = await memberAgent.get('/workflow-review-requests/inbox').expect(200);
const response = await memberAgent.get('/inbox').expect(200);
expect(response.body.data.data).toEqual([]);
});
@ -363,10 +362,7 @@ describe('GET /workflow-review-requests/inbox', () => {
await linkUserToProject(projectAdmin, teamProject, 'project:admin');
const { openRequest } = await seedInboxRequests();
const response = await testServer
.authAgentFor(projectAdmin)
.get('/workflow-review-requests/inbox')
.expect(200);
const response = await testServer.authAgentFor(projectAdmin).get('/inbox').expect(200);
expect(response.body.data.data).toEqual(
expect.arrayContaining([expect.objectContaining({ id: openRequest.id })]),
@ -386,10 +382,7 @@ describe('GET /workflow-review-requests/inbox', () => {
);
await linkToNewWorkflow(secondRequest.id);
const firstPage = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 1 })
.expect(200);
const firstPage = await ownerAgent.get('/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;
@ -397,7 +390,7 @@ describe('GET /workflow-review-requests/inbox', () => {
await requestRepository.delete({ id: firstId });
const secondPage = await ownerAgent
.get('/workflow-review-requests/inbox')
.get('/inbox')
.query({ state: 'open', limit: 1, cursor })
.expect(200);
@ -415,10 +408,7 @@ describe('GET /workflow-review-requests/inbox', () => {
});
await linkToNewWorkflow(request.id);
const response = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 15 })
.expect(200);
const response = await ownerAgent.get('/inbox').query({ state: 'open', limit: 15 }).expect(200);
const item = response.body.data.data.find((row: { id: string }) => row.id === request.id);
expect(item.requester).toEqual({
@ -470,10 +460,7 @@ describe('GET /workflow-review-requests/inbox', () => {
await userRepository.delete({ id: departedCreator.id });
await userRepository.delete({ id: departedReviewer.id });
const response = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 15 })
.expect(200);
const response = await ownerAgent.get('/inbox').query({ state: 'open', limit: 15 }).expect(200);
const rows = response.body.data.data as Array<{
id: string;
@ -496,6 +483,49 @@ describe('GET /workflow-review-requests/inbox', () => {
expect(withoutCreator.reviewers).toEqual([]);
});
test('includes authored and assigned reviews in one list', async () => {
const mine = await seedReview({ projectId: teamProject.id, author: member, title: 'Mine' });
const theirs = await seedReview({
projectId: teamProject.id,
author: owner,
title: 'Assigned',
});
await reviewerRepository.addReviewers(
{ workflowReviewRequestId: theirs.id, userIds: [member.id] },
{},
);
const response = await memberAgent.get('/inbox').expect(200);
expect(response.body.data.data.map((row: { id: string }) => row.id).sort()).toEqual(
[mine.id, theirs.id].sort(),
);
});
test('keeps co-authored reviews visible', async () => {
const request = await seedReview({
projectId: teamProject.id,
author: owner,
title: 'Co-authored',
});
await authorRepository.addAuthor(
{ workflowReviewRequestId: request.id, userId: member.id },
{},
);
const response = await memberAgent.get('/inbox').expect(200);
expect(response.body.data.data).toEqual([expect.objectContaining({ id: request.id })]);
});
test('shows a requester the review for a workflow shared only with them', async () => {
const sharedWorkflow = await createWorkflow({}, owner);
await shareWorkflowWithUsers(sharedWorkflow, [member]);
const request = await seedReview({
projectId: ownerProject.id,
workflowId: sharedWorkflow.id,
author: member,
title: 'Shared with me',
});
const response = await memberAgent.get('/inbox').expect(200);
expect(response.body.data.data).toEqual([expect.objectContaining({ id: request.id })]);
});
describe('category filter', () => {
/** Mirrors the create endpoint: a requester always gets an author row too. */
async function openReviewBy(
@ -521,7 +551,7 @@ describe('GET /workflow-review-requests/inbox', () => {
query: Record<string, unknown>,
): Promise<{ ids: string[]; hasMore: boolean; nextCursor: string | null }> {
const response = await agent
.get('/workflow-review-requests/inbox')
.get('/inbox')
.query({ state: 'open', limit: 15, ...query })
.expect(200);
return {
@ -701,12 +731,14 @@ describe('GET /workflow-review-requests/inbox', () => {
expect(authoredPage.nextCursor).toBeNull();
});
// The category filter ignores `state`, even though the editor sends it only
// for open reviews.
test('filters closed reviews by category too', async () => {
const closedMine = await openReviewBy(member.id, 'Closed mine', 'closed');
await openReviewBy(owner.id, 'Closed theirs', 'closed');
const closedTheirs = await openReviewBy(owner.id, 'Closed theirs', 'closed');
await assignMember(closedTheirs.id);
expect((await inbox(memberAgent, { state: 'closed', category: 'waiting' })).ids).toEqual([
closedTheirs.id,
]);
expect((await inbox(memberAgent, { state: 'closed', category: 'authored' })).ids).toEqual([
closedMine.id,
]);

View File

@ -65,120 +65,63 @@ describe('WorkflowReviewInboxService.listForInbox', () => {
authorizationService.resolveInboxVisibility.mockResolvedValueOnce(visibility);
}
it('returns paginated data with hasMore and nextCursor', async () => {
it('returns ordered source rows without slicing or creating a public cursor', async () => {
mockVisibility();
const rows = [
const rows = ['second', 'first'].map((id) =>
mock<WorkflowReviewRequest>({
id: 'req-2',
id,
projectId: 'proj-1',
title: 'Second',
decision: 'pending',
title: id,
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'),
createdAt: new Date('2026-10-07T00:00:00.000Z'),
updatedAt: new Date('2026-10-07T00:00:00.000Z'),
}),
];
);
workflowReviewInboxRepository.findRequests.mockResolvedValue(rows);
workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds.mockResolvedValue(
new Map([['req-2', { workflowName: 'Linked workflow', workflowVersionId: 'ver-2' }]]),
new Map([['second', { workflowName: 'Linked workflow', workflowVersionId: 'ver-2' }]]),
);
const result = await service.listForInbox(user, { limit: 1 });
const result = await service.listForInbox(user, { state: 'open', limit: 2 });
expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith({
visibility: involvedVisibility,
state: 'open',
category: undefined,
limit: 2,
cursor: undefined,
boundary: undefined,
});
expect(result.data).toHaveLength(1);
expect(result.data[0]?.workflowName).toBe('Linked workflow');
expect(result.data[0]?.workflowVersionId).toBe('ver-2');
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);
// Participants are only resolved for the page, never for the lookahead row.
expect(participantResolver.resolve).toHaveBeenCalledWith([rows[0]]);
expect(result.map((row) => row.id)).toEqual(['second', 'first']);
expect(result[0]).toMatchObject({ type: 'workflow_review', workflowName: 'Linked workflow' });
expect(participantResolver.resolve).toHaveBeenCalledWith(rows);
});
it('decodes the incoming cursor into a keyset boundary', async () => {
it.each([
{ mode: 'beforeTime', createdAt: new Date('2026-10-07T00:00:00.000Z') },
{ mode: 'afterItem', createdAt: new Date('2026-10-07T00:00:00.000Z'), id: 'last' },
] as const)('passes the $mode boundary to its repository', async (boundary) => {
mockVisibility();
workflowReviewInboxRepository.findRequests.mockResolvedValue([]);
workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds.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 });
await service.listForInbox(user, { state: 'closed', limit: 16, boundary });
expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith(
expect.objectContaining({
cursor: { createdAt: new Date('2024-01-02T00:00:00.000Z'), id: 'req-2' },
}),
);
expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith({
visibility: involvedVisibility,
state: 'closed',
category: undefined,
limit: 16,
boundary,
});
});
it('rejects a malformed cursor', async () => {
mockVisibility();
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('category', () => {
beforeEach(() => {
workflowReviewInboxRepository.findRequests.mockResolvedValue([]);
workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds.mockResolvedValue(
new Map(),
);
});
it.each(['authored', 'waiting'] as const)(
'passes category %s through with the requesting user',
async (category) => {
mockVisibility();
await service.listForInbox(user, { limit: 15, category });
expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith(
expect.objectContaining({ category: { userId: 'user-1', category } }),
);
},
);
it('derives the user from the request, never from the query', async () => {
mockVisibility();
const otherUser = mock<User>({ id: 'user-2', role: { slug: 'global:member', scopes: [] } });
await service.listForInbox(otherUser, { limit: 15, category: 'authored' });
expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith(
expect.objectContaining({ category: { userId: 'user-2', category: 'authored' } }),
);
});
it('leaves the query unfiltered when the category is omitted', async () => {
mockVisibility();
await service.listForInbox(user, { limit: 15 });
expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith(
expect.objectContaining({ category: undefined }),
);
});
it('checks the live policy before reading rows', async () => {
workflowReviewPolicyService.get.mockResolvedValue({ enabled: false });
expect(await service.isInboxAvailable()).toBe(false);
await expect(service.listForInbox(user, { state: 'open', limit: 10 })).rejects.toThrow();
expect(workflowReviewInboxRepository.findRequests).not.toHaveBeenCalled();
});
});

View File

@ -14,8 +14,6 @@ const serviceGatedHandlers = new Set([
'create',
'updateVersion',
'decide',
'listInbox',
'getSummary',
'listActivity',
'createComment',
'getDetail',

View File

@ -32,7 +32,7 @@ const workflowValidationService = mockInstance(WorkflowValidationService);
const testServer = utils.setupTestServer({
endpointGroups: ['workflow-reviews', 'workflows'],
enabledFeatures: ['feat:workflowReviews'],
modules: ['workflow-reviews'],
modules: ['workflow-reviews', 'inbox'],
});
let owner: User;
@ -153,7 +153,7 @@ describe('POST /workflow-review-requests/:workflowReviewRequestId/update-version
// Both read surfaces expose every author while keeping the original requester
// canonical. Author order is the frontend's concern, so only membership is asserted.
const inbox = await ownerAgent.get('/workflow-review-requests/inbox').expect(200);
const inbox = await ownerAgent.get('/inbox').expect(200);
const inboxItem = (inbox.body.data.data as WorkflowReviewInboxItem[]).find(
(item) => item.id === request.id,
)!;

View File

@ -1,7 +1,6 @@
import type {
GetWorkflowReviewInboxSummaryResponse,
ListWorkflowReviewInboxQueryDto,
ListWorkflowReviewInboxResponse,
InboxCounts,
InboxWorkflowReviewItem,
WorkflowReviewInboxItem,
WorkflowReviewRequestDetail,
WorkflowReviewRequestWorkflowDetail,
@ -10,7 +9,6 @@ import type {
import {
WorkflowReviewInboxRepository,
WorkflowReviewRequestWorkflowRepository,
type InboxCursor,
type User,
type WorkflowHistory,
type WorkflowReviewRequest,
@ -19,7 +17,8 @@ import {
} from '@n8n/db';
import { Service } from '@n8n/di';
import { BadRequestError } from '@n8n/errors';
import type { InboxSourceQuery } from '../inbox/inbox-source.registry';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { WorkflowReviewAuthorizationService } from './workflow-review-authorization.service';
@ -46,50 +45,40 @@ export class WorkflowReviewInboxService {
private readonly participantResolver: WorkflowReviewParticipantResolver,
) {}
async listForInbox(
user: User,
query: ListWorkflowReviewInboxQueryDto,
): Promise<ListWorkflowReviewInboxResponse> {
await this.featureGate.assertAvailable();
const visibility = await this.authorizationService.resolveInboxVisibility(user);
const { limit } = query;
const rows = await this.workflowReviewInboxRepository.findRequests({
visibility,
state: query.state ?? 'open',
category:
query.category === undefined ? undefined : { userId: user.id, category: query.category },
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 [linkedWorkflowByRequestId, participants] = await Promise.all([
this.workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds(
data.map((row) => row.id),
),
this.participantResolver.resolve(data),
]);
return {
data: data.map((row) =>
this.toInboxItem(
row,
linkedWorkflowByRequestId.get(row.id) ?? null,
participants.for(row.id),
),
),
nextCursor,
hasMore,
};
async isInboxAvailable() {
return await this.featureGate.isAvailable();
}
async getInboxSummaryForUser(user: User): Promise<GetWorkflowReviewInboxSummaryResponse> {
async listForInbox(user: User, query: InboxSourceQuery): Promise<InboxWorkflowReviewItem[]> {
await this.featureGate.assertAvailable();
const visibility = await this.authorizationService.resolveInboxVisibility(user);
const rows = await this.workflowReviewInboxRepository.findRequests({
visibility,
state: query.state,
category:
query.category === undefined ? undefined : { userId: user.id, category: query.category },
limit: query.limit,
boundary: query.boundary,
});
const [linkedWorkflowByRequestId, participants] = await Promise.all([
this.workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds(
rows.map((row) => row.id),
),
this.participantResolver.resolve(rows),
]);
return rows.map((row) => ({
type: 'workflow_review',
...this.toInboxItem(
row,
linkedWorkflowByRequestId.get(row.id) ?? null,
participants.for(row.id),
),
}));
}
async getInboxSummaryForUser(user: User): Promise<InboxCounts> {
await this.featureGate.assertAvailable();
const visibility = await this.authorizationService.resolveInboxVisibility(user);
return await this.workflowReviewInboxRepository.countRequestsByState(visibility);
}
@ -193,31 +182,6 @@ export class WorkflowReviewInboxService {
};
}
/**
* 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 };
}
/** The review fields shared by the inbox card and the detail response. */
private toReviewSummary(
entity: WorkflowReviewRequest,

View File

@ -6,10 +6,7 @@ import {
ListWorkflowReviewActivityQueryDto,
ListWorkflowReviewRequestsQueryDto,
UpdateWorkflowReviewRequestVersionDto,
type GetWorkflowReviewInboxSummaryResponse,
type ListWorkflowReviewActivityResponse,
type ListWorkflowReviewInboxResponse,
ListWorkflowReviewInboxQueryDto,
type WorkflowReviewActivityEntry,
type WorkflowReviewRequestDetail,
} from '@n8n/api-types';
@ -96,26 +93,6 @@ export class WorkflowReviewRequestsController {
);
}
/** Cross-project inbox. `GET /` remains the workflow-specific list used by LIGO-838. */
@Get('/inbox')
@Licensed('feat:workflowReviews')
async listInbox(
req: AuthenticatedRequest,
_res: Response,
@Query query: ListWorkflowReviewInboxQueryDto,
): Promise<ListWorkflowReviewInboxResponse> {
return await this.workflowReviewInboxService.listForInbox(req.user, query);
}
@Get('/summary')
@Licensed('feat:workflowReviews')
async getSummary(
req: AuthenticatedRequest,
_res: Response,
): Promise<GetWorkflowReviewInboxSummaryResponse> {
return await this.workflowReviewInboxService.getInboxSummaryForUser(req.user);
}
@Get('/:workflowReviewRequestId/activity')
@Licensed('feat:workflowReviews')
async listActivity(

View File

@ -9,6 +9,15 @@ import { WorkflowMutationHooksProxy, WorkflowPublishGuardProxy } from '@n8n/back
export class WorkflowReviewsModule implements ModuleInterface {
async init() {
await import('./workflow-review-requests.controller.js');
const { InboxSourceRegistry } = await import('../inbox/inbox-source.registry.js');
const { WorkflowReviewInboxService } = await import('./workflow-review-inbox.service.js');
const inbox = Container.get(WorkflowReviewInboxService);
Container.get(InboxSourceRegistry).register({
type: 'workflow_review',
isEnabled: async () => await inbox.isInboxAvailable(),
list: async (user, query) => await inbox.listForInbox(user, query),
count: async (user) => await inbox.getInboxSummaryForUser(user),
});
const { WorkflowReviewPublishGuard } = await import(
'./workflow-review-publish-guard.service.js'
);

View File

@ -15,6 +15,7 @@ import type { CredentialsOverwrites } from '@/credentials-overwrites';
import { License } from '@/license';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { MfaService } from '@/mfa/mfa.service';
import type { InboxService } from '@/modules/inbox/inbox.service';
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
import type { PushConfig } from '@/push/push.config';
import type { AiUsageService } from '@/services/ai-usage.service';
@ -206,6 +207,7 @@ describe('FrontendService', () => {
});
const workflowReviewPolicyService = mock<WorkflowReviewPolicyService>();
const inboxService = mock<InboxService>();
const createMockService = () => {
Container.set(
@ -239,6 +241,7 @@ describe('FrontendService', () => {
aiUsageService,
workflowRepository,
workflowReviewPolicyService,
inboxService,
),
license,
};
@ -247,6 +250,7 @@ describe('FrontendService', () => {
beforeEach(() => {
originalEnv = { ...process.env };
vi.clearAllMocks();
moduleRegistry.isActive.mockReturnValue(false);
globalConfig.diagnostics.enabled = false;
globalConfig.endpoints.frontendHealthCheckTimeoutMs = 5000;
globalConfig.aiAssistant.baseUrl = '';
@ -280,6 +284,44 @@ describe('FrontendService', () => {
});
describe('getSettings', () => {
it('refreshes Inbox availability from registered sources on each request', async () => {
moduleRegistry.isActive.mockReturnValue(true);
inboxService.getSettings
.mockResolvedValueOnce({
enabled: true,
availableTypes: ['workflow_review'],
failedTypes: [],
})
.mockResolvedValueOnce({
enabled: true,
availableTypes: [],
failedTypes: ['workflow_review'],
});
const { service } = createMockService();
expect((await service.getSettings()).inbox).toEqual({
enabled: true,
availableTypes: ['workflow_review'],
failedTypes: [],
});
expect((await service.getSettings()).inbox).toEqual({
enabled: true,
availableTypes: [],
failedTypes: ['workflow_review'],
});
expect(inboxService.getSettings).toHaveBeenCalledTimes(2);
});
it('does not query Inbox sources when its module is inactive', async () => {
const { service } = createMockService();
expect((await service.getSettings()).inbox).toEqual({
enabled: false,
availableTypes: [],
failedTypes: [],
});
expect(inboxService.getSettings).not.toHaveBeenCalled();
});
it('should return frontend settings', async () => {
const { service } = createMockService();
const settings = await service.getSettings();

View File

@ -26,6 +26,7 @@ import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { MfaService } from '@/mfa/mfa.service';
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
import { InboxService } from '@/modules/inbox/inbox.service';
import { isApiKeyAuthEnabled } from '@/public-api';
import { PushConfig } from '@/push/push.config';
import { OwnershipService } from '@/services/ownership.service';
@ -141,6 +142,7 @@ export class FrontendService {
private readonly aiUsageService: AiUsageService,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowReviewPolicyService: WorkflowReviewPolicyService,
private readonly inboxService: InboxService,
) {
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
credentialsOverwrites.registerReloadHandler(async () => await this.generateTypes());
@ -666,6 +668,9 @@ export class FrontendService {
} else {
delete this.settings.workflowReviews;
}
this.settings.inbox = this.moduleRegistry.isActive('inbox')
? await this.inboxService.getSettings()
: { enabled: false, availableTypes: [], failedTypes: [] };
this.settings.executionMode = this.globalConfig.executions.mode;

View File

@ -67,6 +67,7 @@ type EndpointGroup =
| 'type-availability-policies';
type ModuleName =
| 'inbox'
| 'insights'
| 'external-secrets'
| 'community-packages'

View File

@ -93,9 +93,9 @@ describe('useIntersectionObserver()', () => {
observe(element);
// Simulate intersection
const callback = MockIntersectionObserver.getInstance().__callback;
if (callback) {
callback([createMockEntry(element, true)], {} as IntersectionObserver);
const onIntersect = MockIntersectionObserver.getInstance().__callback;
if (onIntersect) {
onIntersect([createMockEntry(element, true)], {} as IntersectionObserver);
}
expect(mockCallback).toHaveBeenCalledTimes(1);
@ -112,9 +112,9 @@ describe('useIntersectionObserver()', () => {
observe(element);
// Simulate no intersection
const callback = MockIntersectionObserver.getInstance().__callback;
if (callback) {
callback([createMockEntry(element, false)], {} as IntersectionObserver);
const onIntersect = MockIntersectionObserver.getInstance().__callback;
if (onIntersect) {
onIntersect([createMockEntry(element, false)], {} as IntersectionObserver);
}
expect(mockCallback).not.toHaveBeenCalled();
@ -131,9 +131,9 @@ describe('useIntersectionObserver()', () => {
observe(element);
// Simulate intersection
const callback = MockIntersectionObserver.getInstance().__callback;
if (callback) {
callback([createMockEntry(element, true)], {} as IntersectionObserver);
const onIntersect = MockIntersectionObserver.getInstance().__callback;
if (onIntersect) {
onIntersect([createMockEntry(element, true)], {} as IntersectionObserver);
}
expect(MockIntersectionObserver.getInstance().disconnect).toHaveBeenCalledTimes(1);
@ -151,9 +151,9 @@ describe('useIntersectionObserver()', () => {
observe(element);
// Simulate intersection
const callback = MockIntersectionObserver.getInstance().__callback;
if (callback) {
callback([createMockEntry(element, true)], {} as IntersectionObserver);
const onIntersect = MockIntersectionObserver.getInstance().__callback;
if (onIntersect) {
onIntersect([createMockEntry(element, true)], {} as IntersectionObserver);
}
expect(MockIntersectionObserver.getInstance().disconnect).not.toHaveBeenCalled();

View File

@ -0,0 +1,92 @@
import { ref, onBeforeUnmount, type Ref } from 'vue';
export interface UseIntersectionObserverOptions {
/**
* The root element for intersection detection (scrollable container)
*/
root: Ref<Element | null>;
/**
* Threshold for intersection (0.0 to 1.0)
* @default 0.01
*/
threshold?: number;
/**
* Callback to execute when element intersects
*/
onIntersect: () => void;
/**
* Whether to disconnect observer after first intersection
* @default true
*/
once?: boolean;
}
/**
* Composable for observing element intersections with automatic cleanup
*
* @example
* const { observe } = useIntersectionObserver({
* root: containerRef,
* onIntersect: () => loadMore(),
* });
*
* // Observe last item in list
* observe(lastItemElement);
*/
export function useIntersectionObserver(options: UseIntersectionObserverOptions) {
const observer = ref<IntersectionObserver | null>(null);
/**
* Stop observing and clean up the observer
*/
const disconnect = () => {
if (observer.value) {
observer.value.disconnect();
observer.value = null;
}
};
/**
* Start observing an element for intersection
* Automatically cleans up any existing observer before creating a new one
*/
const observe = (element: Element | null | undefined) => {
if (!element) return;
// Clean up existing observer if any
disconnect();
observer.value = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
// Disconnect first. The callback can call observe() and create a new observer.
if (options.once !== false) {
disconnect();
}
options.onIntersect();
}
},
{
root: options.root.value,
threshold: options.threshold ?? 0.01,
},
);
observer.value.observe(element);
};
// Cleanup observer when component unmounts
onBeforeUnmount(() => {
disconnect();
});
return {
observer,
observe,
disconnect,
};
}

View File

@ -0,0 +1,23 @@
import { useI18n } from '@n8n/i18n';
export const generateVersionLabelFromId = (versionId: string) => {
return `Version ${versionId.substring(0, 8)}`;
};
export const getVersionLabel = ({
workflowHistory,
currentVersionId,
}: {
workflowHistory: { versionId: string; name?: string | null };
currentVersionId?: string;
}) => {
const i18n = useI18n();
if (workflowHistory.name) {
return workflowHistory.name;
}
const isCurrentVersion = workflowHistory.versionId === currentVersionId;
return isCurrentVersion
? i18n.baseText('workflowHistory.item.currentChanges')
: generateVersionLabelFromId(workflowHistory.versionId);
};

View File

@ -1,3 +1,5 @@
import type { WorkflowReviewVersionSnapshot } from '@n8n/api-types';
/**
* UI a module renders but does not own.
*
@ -9,8 +11,9 @@
* decide between local and remote search, and the search itself all live in
* `features/collaboration/projects`, which sits above the module layer. A module
* that needs to filter by project renders this instead of importing any of it.
* `workflow-diff` renders plain workflow snapshots with the editor canvas.
*/
export type ModuleComponentSlot = 'project-filter';
export type ModuleComponentSlot = 'project-filter' | 'workflow-diff';
/**
* The `v-model` value of the `project-filter` slot. `null` means "all projects".
@ -20,3 +23,14 @@ export type ModuleComponentSlot = 'project-filter';
* originates in the host.
*/
export type SlotProjectSelection = { id: string } | null;
/** Plain snapshots keep the editor canvas types inside the shell. */
export type SlotWorkflowDiffProps = {
workflowId: string;
workflowName: string;
sourceSnapshot?: WorkflowReviewVersionSnapshot;
targetSnapshot?: WorkflowReviewVersionSnapshot;
sourceLabel?: string;
targetLabel?: string;
showFullscreenButton?: boolean;
};

View File

@ -44,6 +44,7 @@ export const sourcePackages = [
* The module tsconfig base holds that boundary.
*/
export const modulePackages: Array<{ name: string; dir: string; entry?: boolean }> = [
{ name: '@n8n/frontend-module-inbox', dir: 'modules/inbox/frontend' },
{ name: '@n8n/frontend-module-mcp', dir: 'modules/mcp/frontend' },
{
name: '@n8n/frontend-module-type-availability-policies',

View File

@ -1,4 +1,19 @@
{
"inbox.title": "Reviews",
"inbox.tabs.open": "Open",
"inbox.tabs.closed": "Closed",
"inbox.partial": "Some items couldn’t load. Retry to refresh the list.",
"inbox.loadError": "Couldn't load reviews",
"inbox.loadMore": "Load more",
"inbox.empty.open": "No open reviews",
"inbox.empty.closed": "No closed reviews",
"inbox.empty.body.open": "Reviews you authored or participate in appear here.",
"inbox.empty.body.closed": "Reviews that were approved, rejected, or cancelled appear here.",
"inbox.items.open": "Open reviews",
"inbox.items.closed": "Closed reviews",
"inbox.noSelection.title.open": "{count} open review | {count} open reviews",
"inbox.noSelection.title.closed": "{count} closed review | {count} closed reviews",
"inbox.noSelection.body": "Select a review from the list to see its activity and changes.",
"_reusableBaseText": {
"cancel": "Cancel",
"codeNodeEditor": {
@ -6118,17 +6133,9 @@
"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.openReviews": "Open reviews",
"workflowReviews.closedReviews": "Closed reviews",
"workflowReviews.page.title": "Reviews",
"workflowReviews.sidebar.tabs.open": "Open",
"workflowReviews.sidebar.tabs.closed": "Closed",
"workflowReviews.sidebar.section.waiting.title": "Waiting for your review",
"workflowReviews.sidebar.section.waiting.titleAdmin": "Waiting for review",
"workflowReviews.sidebar.section.authored.title": "Authored by you",
"workflowReviews.sidebar.loadMore": "Load more",
"workflowReviews.sidebar.error": "Couldn't load reviews",
"workflowReviews.status.open": "Open",
"workflowReviews.status.closed": "Closed",
"workflowReviews.decision.pending": "Waiting for review",
@ -6198,13 +6205,6 @@
"workflowReviews.detail.decision.approveAndPublish": "Approve and publish",
"workflowReviews.detail.decision.requestChanges": "Request changes",
"workflowReviews.detail.decision.ineligible.author": "You contributed a version to this review.",
"workflowReviews.emptyState.title.open": "No open reviews",
"workflowReviews.emptyState.title.closed": "No closed reviews",
"workflowReviews.emptyState.body.open": "Reviews you authored or participate in appear here.",
"workflowReviews.emptyState.body.closed": "Reviews that were approved, rejected, or cancelled appear here.",
"workflowReviews.noSelection.title.open": "{count} open review | {count} open reviews",
"workflowReviews.noSelection.title.closed": "{count} closed review | {count} closed reviews",
"workflowReviews.noSelection.body": "Select a review from the list to see its activity and changes.",
"workflowReviews.error.load": "Could not load workflow reviews",
"workflowReviews.decision.error.title": "Could not submit review decision",
"workflowReviews.decision.approved.published.title": "Review approved",

View File

@ -132,6 +132,56 @@ describe('settings.store', () => {
setActivePinia(createPinia());
});
describe('setWorkflowReviewsPolicy', () => {
beforeEach(() => {
const store = useSettingsStore();
store.settings.activeModules = ['inbox', 'workflow-reviews'];
store.settings.enterprise = { ...mockSettings.enterprise, workflowReviews: true };
store.settings.inbox = { enabled: false, availableTypes: [], failedTypes: [] };
});
it('disables Inbox when its last source is disabled', () => {
const store = useSettingsStore();
store.setWorkflowReviewsPolicy({ enabled: true });
expect(store.settings.inbox?.enabled).toBe(true);
store.setWorkflowReviewsPolicy({ enabled: false });
expect(store.settings.inbox).toEqual({
enabled: false,
availableTypes: [],
failedTypes: [],
});
});
it.each([
{ licensed: false, activeModules: ['inbox', 'workflow-reviews'] },
{ licensed: true, activeModules: ['inbox'] },
{ licensed: true, activeModules: ['workflow-reviews'] },
])('keeps license and module gates: %j', ({ licensed, activeModules }) => {
const store = useSettingsStore();
store.settings.enterprise.workflowReviews = licensed;
store.settings.activeModules = activeModules;
store.setWorkflowReviewsPolicy({ enabled: true });
expect(store.settings.inbox).toEqual({
enabled: false,
availableTypes: [],
failedTypes: [],
});
});
it('does not invent Inbox settings when they are absent', () => {
const store = useSettingsStore();
delete store.settings.inbox;
store.setWorkflowReviewsPolicy({ enabled: true });
expect(store.settings.inbox).toBeUndefined();
});
});
describe('isAutosaveEnabled', () => {
it('should return true when workflowsAutosaveDisabled is false', async () => {
getSettings.mockResolvedValueOnce({

View File

@ -316,6 +316,21 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const setWorkflowReviewsPolicy = (policy: WorkflowReviewsPolicy) => {
settings.value.workflowReviews = policy;
const inbox = settings.value.inbox;
if (!inbox || !isModuleActive('inbox')) return;
// Update Inbox access from the saved policy.
inbox.availableTypes = inbox.availableTypes.filter((type) => type !== 'workflow_review');
inbox.failedTypes = inbox.failedTypes.filter((type) => type !== 'workflow_review');
if (
policy.enabled &&
isEnterpriseFeatureEnabled.value.workflowReviews &&
isModuleActive('workflow-reviews')
) {
inbox.availableTypes.push('workflow_review');
}
inbox.enabled = inbox.availableTypes.length > 0 || inbox.failedTypes.length > 0;
};
const setSaveDataErrorExecution = (newValue: WorkflowSettings.SaveDataExecution) => {

View File

@ -54,6 +54,7 @@
"@n8n/eslint-config": "workspace:*",
"@n8n/expression-runtime": "workspace:*",
"@n8n/frontend-constants": "workspace:*",
"@n8n/frontend-module-inbox": "workspace:*",
"@n8n/frontend-module-insights": "workspace:*",
"@n8n/frontend-module-instance-registry": "workspace:*",
"@n8n/frontend-module-mcp": "workspace:*",

View File

@ -10,6 +10,13 @@ import { defineAsyncComponent } from 'vue';
* design-system tree), which is exactly what a module renders a slot to avoid.
*/
export const registerComponentSlots = () => {
componentRegistry.register(
'workflow-diff',
defineAsyncComponent(
async () => await import('@/features/workflows/workflowDiff/WorkflowDiffSlot.vue'),
),
);
componentRegistry.register(
'project-filter',
defineAsyncComponent(

View File

@ -47,8 +47,8 @@ import { ResponseError } from '@n8n/rest-api-client';
// Hoisted: the vue-router mock factory runs before module-level consts initialize
const { mockRouterPush, mockRouterResolve } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockRouterResolve: vi.fn(({ params }: { params: { reviewRequestId: string } }) => ({
href: `/reviews/${params.reviewRequestId}`,
mockRouterResolve: vi.fn(({ params }: { params: { reviewId: string } }) => ({
href: `/inbox/reviews/${params.reviewId}`,
})),
}));
@ -712,7 +712,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
expect(mockShowToast).toHaveBeenCalledWith({
type: 'success',
title: 'Latest changes submitted to the existing review',
message: '<a href="/reviews/req-1">Open review</a>',
message: '<a href="/inbox/reviews/req-1">Open review</a>',
onClick: expect.any(Function),
});
const toastConfig = mockShowToast.mock.calls.at(-1)?.[0];
@ -725,7 +725,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
expect(preventDefault).toHaveBeenCalledOnce();
expect(mockRouterPush).toHaveBeenCalledWith({
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
params: { reviewRequestId: 'req-1' },
params: { reviewId: 'req-1' },
});
});
@ -819,7 +819,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
expect(mockShowToast).toHaveBeenCalledWith({
type: 'success',
title: 'Workflow version submitted for review',
message: '<a href="/reviews/review-1">Open review</a>',
message: '<a href="/inbox/reviews/review-1">Open review</a>',
onClick: expect.any(Function),
});
});
@ -845,7 +845,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
expect(mockShowToast).toHaveBeenCalledWith({
type: 'success',
title: 'Workflow version submitted for review',
message: '<a href="/reviews/review-1">Open review</a>',
message: '<a href="/inbox/reviews/review-1">Open review</a>',
onClick: expect.any(Function),
});
});
@ -1630,7 +1630,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
expect(mockRouterPush).toHaveBeenCalledWith({
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
params: { reviewRequestId: 'req-1' },
params: { reviewId: 'req-1' },
query: undefined,
});
});

View File

@ -320,7 +320,7 @@ const flushSaveForReview = async (): Promise<string | undefined> => {
const showReviewToast = (titleKey: BaseTextKey, reviewRequestId: string) => {
const reviewRoute = {
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
params: { reviewRequestId },
params: { reviewId: reviewRequestId },
};
const reviewUrl = router.resolve(reviewRoute).href;
@ -373,7 +373,7 @@ const onOpenReviewFromBanner = async () => {
await router.push({
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
params: { reviewRequestId: review.id },
params: { reviewId: review.id },
// The inbox opens on its "Open" tab, so a closed review would land in a list
// that cannot contain its card. Keyed off the review's own state rather than
// an approved decision, so any way a review gets closed still lands right.

View File

@ -1,91 +1,2 @@
import { ref, onBeforeUnmount, type Ref } from 'vue';
export interface UseIntersectionObserverOptions {
/**
* The root element for intersection detection (scrollable container)
*/
root: Ref<Element | null>;
/**
* Threshold for intersection (0.0 to 1.0)
* @default 0.01
*/
threshold?: number;
/**
* Callback to execute when element intersects
*/
onIntersect: () => void;
/**
* Whether to disconnect observer after first intersection
* @default true
*/
once?: boolean;
}
/**
* Composable for observing element intersections with automatic cleanup
*
* @example
* const { observe } = useIntersectionObserver({
* root: containerRef,
* onIntersect: () => loadMore(),
* });
*
* // Observe last item in list
* observe(lastItemElement);
*/
export function useIntersectionObserver(options: UseIntersectionObserverOptions) {
const observer = ref<IntersectionObserver | null>(null);
/**
* Stop observing and clean up the observer
*/
const disconnect = () => {
if (observer.value) {
observer.value.disconnect();
observer.value = null;
}
};
/**
* Start observing an element for intersection
* Automatically cleans up any existing observer before creating a new one
*/
const observe = (element: Element | null | undefined) => {
if (!element) return;
// Clean up existing observer if any
disconnect();
observer.value = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
options.onIntersect();
if (options.once !== false) {
disconnect();
}
}
},
{
root: options.root.value,
threshold: options.threshold ?? 0.01,
},
);
observer.value.observe(element);
};
// Cleanup observer when component unmounts
onBeforeUnmount(() => {
disconnect();
});
return {
observer,
observe,
disconnect,
};
}
export { useIntersectionObserver } from '@n8n/composables/useIntersectionObserver';
export type { UseIntersectionObserverOptions } from '@n8n/composables/useIntersectionObserver';

View File

@ -51,9 +51,6 @@ export const LOCAL_STORAGE_WORKFLOW_REVIEW_PUBLISH_CHOICE_HIDDEN = (userId: stri
`${userId}_N8N_WORKFLOW_REVIEW_PUBLISH_CHOICE_HIDDEN`;
export const LOCAL_STORAGE_WORKFLOW_REVIEW_SUBMITTED_DIALOG_HIDDEN = (userId: string) =>
`${userId}_N8N_WORKFLOW_REVIEW_SUBMITTED_DIALOG_HIDDEN`;
export const LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS = (userId: string) =>
`${userId}_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS`;
export const LOCAL_STORAGE_WORKFLOW_REVIEW_SIDEBAR_WIDTH = 'N8N_WORKFLOW_REVIEW_SIDEBAR_WIDTH';
export const LOCAL_STORAGE_EVALUATIONS_CANVAS_INFO_CARD_DISMISSED =
'N8N_EVALUATIONS_CANVAS_INFO_CARD_DISMISSED';
export const LOCAL_STORAGE_CANVAS_GROUP_EXPANDED = 'N8N_CANVAS_GROUP_EXPANDED';

View File

@ -4,7 +4,7 @@ import { MCPModule } from '@n8n/frontend-module-mcp/mcp.module';
import { ChatModule } from '@/features/ai/chatHub/module.descriptor';
import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor';
import { AgentsModule } from '@/features/agents/module.descriptor';
import { WorkflowReviewsModule } from '@/features/workflow-reviews/module.descriptor';
import { InboxModule } from '@n8n/frontend-module-inbox';
import { InstanceRegistryModule } from '@n8n/frontend-module-instance-registry';
import { OtelModule } from '@n8n/frontend-module-otel';
import { InsightsModule } from '@n8n/frontend-module-insights/insights.module';
@ -36,7 +36,7 @@ export const modules: FrontendModuleDescription[] = [
InstanceAiModule,
AgentsModule,
OtelModule,
WorkflowReviewsModule,
InboxModule,
InstanceRegistryModule,
InsightsModule,
PromotionsModule,

View File

@ -24,8 +24,7 @@ import {
} from '@/features/ai/instanceAi/constants';
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store';
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants';
import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature';
import { INBOX_VIEW } from '@n8n/frontend-module-inbox';
import {
AGENT_N8N_CHAT_VIEW,
AGENT_N8N_CHAT_RECENT_THREADS_LIMIT,
@ -212,13 +211,13 @@ function onChatItemClick(item: RecentChatItem): void {
agentTelemetry.trackClickedSidebarItem({ item: 'chat', chatType: item.kind });
}
const { isWorkflowReviewsEnabled: isWorkflowReviewsNavVisible } = useWorkflowReviewsFeature();
const isInboxVisible = computed(() => settingsStore.settings.inbox?.enabled === true);
const workflowReviews = computed<IMenuItem>(() => ({
id: 'workflow-reviews',
const inbox = computed<IMenuItem>(() => ({
id: 'inbox',
icon: 'message-square-text',
label: locale.baseText('workflowReviews.menu.title'),
route: { to: { name: WORKFLOW_REVIEW_REQUESTS_VIEW } },
label: locale.baseText('inbox.title'),
route: { to: { name: INBOX_VIEW } },
}));
const chat = computed<IMenuItem>(() => ({
id: 'chat',
@ -290,11 +289,11 @@ onBeforeUnmount(() => {
data-test-id="project-shared-menu-item"
/>
<N8nMenuItem
v-if="isWorkflowReviewsNavVisible"
:item="workflowReviews"
v-if="isInboxVisible"
:item="inbox"
:compact="props.collapsed"
:active="sidebarActiveTabId === 'workflow-reviews'"
data-test-id="project-workflow-reviews-menu-item"
:active="sidebarActiveTabId === 'inbox'"
data-test-id="project-inbox-menu-item"
/>
<N8nMenuItem
v-if="isChatLinkAvailable"

View File

@ -1,7 +1,7 @@
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';
import { isInboxRoute } from '@n8n/frontend-module-inbox';
/**
* This composable holds reusable logic that detects the current page type
@ -23,7 +23,7 @@ export const useProjectPages = () => {
route.name === VIEWS.SHARED_CREDENTIALS,
);
const isWorkflowReviewsSubPage = computed(() => route.name === WORKFLOW_REVIEW_REQUESTS_VIEW);
const isWorkflowReviewsSubPage = computed(() => isInboxRoute(route));
return reactive({
isOverviewSubPage,

View File

@ -1,5 +1,5 @@
import { setActivePinia, createPinia } from 'pinia';
import { reactive } from 'vue';
import { nextTick, reactive } from 'vue';
import { vi } from 'vitest';
import { useProjectsStore } from './projects.store';
import * as projectsApi from './projects.api';
@ -338,4 +338,16 @@ describe('useProjectsStore.setProjectNavActiveIdByWorkflowHomeProject', () => {
expect(store.currentProject).toBeNull();
expect(store.projectNavActiveId).toBe('shared');
});
it('clears the workflow project context when opening Reviews', async () => {
const store = makeStore();
store.currentProject = makeProject('team-1');
await store.setProjectNavActiveIdByWorkflowHomeProject(store.currentProject);
route.path = '/inbox';
await nextTick();
expect(store.projectNavActiveId).toBe('inbox');
expect(store.currentProject).toBeNull();
});
});

View File

@ -357,8 +357,8 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
setCurrentProject(null);
}
if (newRoute?.path?.includes('/reviews')) {
projectNavActiveId.value = 'workflow-reviews';
if (newRoute?.path?.includes('/inbox')) {
projectNavActiveId.value = 'inbox';
setCurrentProject(null);
}

View File

@ -939,6 +939,25 @@ describe('SecuritySettings', () => {
});
});
it('updates Inbox access after saving the policy without reloading settings', async () => {
settingsStore.settings.activeModules = ['inbox', 'workflow-reviews'];
settingsStore.settings.inbox = { enabled: false, availableTypes: [], failedTypes: [] };
updateSecuritySettings.mockResolvedValue({ workflowReviews: { enabled: true } });
const { findByTestId } = renderView();
const toggle = await findByTestId('security-workflow-reviews-toggle');
await userEvent.click(toggle);
await waitFor(() => expect(showToast).toHaveBeenCalled());
expect(settingsStore.settings.workflowReviews).toEqual({ enabled: true });
expect(settingsStore.settings.inbox).toEqual({
enabled: true,
availableTypes: ['workflow_review'],
failedTypes: [],
});
expect(toggle).not.toHaveClass('is-disabled');
expect(showError).not.toHaveBeenCalled();
expect(settingsStore.getSettings).not.toHaveBeenCalled();
});
it('should disable workflow reviews toggle when managed by env', async () => {
getSecuritySettings.mockResolvedValue({
...defaultSettings,

View File

@ -1,386 +0,0 @@
import type { WorkflowReviewInboxItem } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent } from '@testing-library/vue';
import { nextTick } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS } from '@/app/constants/localStorage';
import { useUsersStore } from '@n8n/stores/users.store';
import WorkflowReviewRequestsSidebar from './WorkflowReviewRequestsSidebar.vue';
import type { ReviewInboxSidebarSection } from './WorkflowReviewRequestsSidebar.vue';
const observe = vi.fn();
vi.mock('@/app/composables/useIntersectionObserver', () => ({
useIntersectionObserver: () => ({
observe: (...args: unknown[]) => observe(...args),
disconnect: vi.fn(),
observer: { value: null },
}),
}));
const renderComponent = createComponentRenderer(WorkflowReviewRequestsSidebar);
const CURRENT_USER_ID = 'current-user';
const reviewers = [
{
id: 'user-reviewer-1',
email: 'reviewer1@example.com',
firstName: 'Rey',
lastName: 'One',
},
{
id: 'user-reviewer-2',
email: 'reviewer2@example.com',
firstName: 'Reba',
lastName: 'Two',
},
{
id: 'user-reviewer-3',
email: 'reviewer3@example.com',
firstName: 'Ron',
lastName: 'Three',
},
];
function makeItem(overrides: Partial<WorkflowReviewInboxItem> = {}): WorkflowReviewInboxItem {
return {
id: 'req-1',
projectId: 'proj-1',
title: 'Needs review',
workflowName: 'My workflow',
requester: null,
authors: [],
reviewers: reviewers.slice(0, 1),
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
...overrides,
workflowVersionId: overrides.workflowVersionId ?? null,
};
}
function makeSection(
key: ReviewInboxSidebarSection['key'],
overrides: Partial<ReviewInboxSidebarSection> = {},
): ReviewInboxSidebarSection {
return {
key,
items: [],
loadingMore: false,
hasMore: false,
error: null,
...overrides,
};
}
function openProps(overrides: Partial<ReviewInboxSidebarSection>[] = [{}, {}]) {
return {
sections: [makeSection('waiting', overrides[0]), makeSection('authored', overrides[1])],
loading: false,
initialLoadFailed: false,
activeTab: 'open' as const,
openCount: 1,
closedCount: 0,
selectedId: null,
};
}
function closedProps(overrides: Partial<ReviewInboxSidebarSection> = {}) {
return {
sections: [makeSection('closed', overrides)],
loading: false,
initialLoadFailed: false,
activeTab: 'closed' as const,
openCount: 0,
closedCount: 1,
selectedId: null,
};
}
describe('WorkflowReviewRequestsSidebar', () => {
beforeEach(() => {
localStorage.clear();
observe.mockClear();
createTestingPinia();
const usersStore = mockedStore(useUsersStore);
usersStore.currentUser = {
id: CURRENT_USER_ID,
email: 'me@example.com',
firstName: 'Me',
lastName: 'User',
} as never;
usersStore.currentUserId = CURRENT_USER_ID;
});
describe('cards', () => {
it.each([
{ state: 'open' as const, decision: 'pending' as const, label: 'Open | Waiting for review' },
{
state: 'open' as const,
decision: 'changes_requested' as const,
label: 'Open | Changes requested',
},
{ state: 'closed' as const, decision: 'approved' as const, label: 'Closed | Approved' },
{ state: 'closed' as const, decision: 'pending' as const, label: 'Closed | No decision' },
])('maps $state + $decision to the "$label" status indicator', ({ state, decision, label }) => {
const props =
state === 'closed'
? closedProps({ items: [makeItem({ state, decision })] })
: openProps([{ items: [makeItem({ state, decision })] }, {}]);
const { getByTestId } = renderComponent({ props });
expect(getByTestId('workflow-review-request-status-dot')).toHaveAttribute(
'aria-label',
label,
);
});
});
describe('sections', () => {
it('renders both open headers in order, waiting before authored', () => {
const { getAllByTestId } = renderComponent({
props: openProps([{ items: [makeItem()] }, { items: [makeItem({ id: 'req-2' })] }]),
});
const headers = getAllByTestId('workflow-review-section-header');
expect(headers.map((header) => header.dataset.section)).toEqual(['waiting', 'authored']);
expect(headers[0]).toHaveTextContent('Waiting for your review');
expect(headers[1]).toHaveTextContent('Authored by you');
});
it('drops a settled empty section, keeping the populated sibling', () => {
const { getAllByTestId } = renderComponent({
props: openProps([{ items: [makeItem()] }, {}]),
});
const headers = getAllByTestId('workflow-review-section-header');
expect(headers.map((header) => header.dataset.section)).toEqual(['waiting']);
expect(getAllByTestId('workflow-review-request-row')).toHaveLength(1);
});
it('drops every section when the whole tab is empty', () => {
const { queryAllByTestId } = renderComponent({ props: openProps() });
expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0);
expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0);
});
it('renders no header on the closed tab', () => {
const { queryAllByTestId, getAllByTestId } = renderComponent({
props: closedProps({ items: [makeItem({ state: 'closed' })] }),
});
expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0);
expect(getAllByTestId('workflow-review-request-row')).toHaveLength(1);
});
it('gives each section its own labelled listbox holding only options', () => {
const { container } = renderComponent({
props: openProps([{ items: [makeItem()] }, { items: [makeItem({ id: 'req-2' })] }]),
});
const listboxes = container.querySelectorAll('[role="listbox"]');
expect(listboxes).toHaveLength(2);
expect(listboxes[0].getAttribute('aria-labelledby')).toBe(
'workflow-review-section-header-waiting',
);
expect(listboxes[0].querySelectorAll('[role="option"]')).toHaveLength(1);
expect(listboxes[0].querySelector('button')).toBeNull();
});
it('keeps the section headers and controls outside any listbox', () => {
const { getAllByTestId } = renderComponent({
props: openProps([{ error: new Error('nope'), hasMore: true }, {}]),
});
for (const testId of [
'workflow-review-section-header',
'workflow-review-section-retry',
'workflow-review-section-load-more',
]) {
for (const element of getAllByTestId(testId)) {
expect(element.closest('[role="listbox"]')).toBeNull();
}
}
});
// The member wording is already covered by the header-order test above.
it('drops the possessive waiting labels for an admin', () => {
mockedStore(useUsersStore).isAdminOrOwner = true;
const { getAllByTestId } = renderComponent({
props: openProps([{ items: [makeItem()] }, {}]),
});
expect(getAllByTestId('workflow-review-section-header')[0]).toHaveTextContent(
'Waiting for review',
);
});
it('shows one skeleton and no headers while the active tab loads', () => {
const { getAllByTestId, queryAllByTestId } = renderComponent({
props: { ...openProps(), loading: true },
});
expect(getAllByTestId('workflow-review-list-skeleton')).toHaveLength(1);
expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0);
});
it('renders the tabs without counts while the summary is unavailable', () => {
const { getByTestId } = renderComponent({
props: { ...openProps(), openCount: null, closedCount: null },
});
const tabs = getByTestId('workflow-reviews-tabs');
expect(tabs).toHaveTextContent('Open');
expect(tabs.querySelectorAll('.n8n-tag')).toHaveLength(0);
});
it('keeps the whole-list skeleton while either open section is loading', () => {
const { getAllByTestId, queryAllByTestId } = renderComponent({
props: {
...openProps([{}, { items: [makeItem({ id: 'authored-1' })] }]),
loading: true,
},
});
expect(getAllByTestId('workflow-review-list-skeleton')).toHaveLength(1);
expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0);
});
});
describe('collapsing', () => {
it('hides rows, flips aria-expanded, and persists the choice', async () => {
const { getAllByTestId, queryAllByTestId } = renderComponent({
props: openProps([{ items: [makeItem()] }, {}]),
});
const [waitingHeader] = getAllByTestId('workflow-review-section-header');
expect(waitingHeader).toHaveAttribute('aria-expanded', 'true');
expect(waitingHeader).toHaveAttribute(
'aria-controls',
'workflow-review-section-group-waiting',
);
await fireEvent.click(waitingHeader);
expect(waitingHeader).toHaveAttribute('aria-expanded', 'false');
expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0);
await nextTick();
expect(
localStorage.getItem(
LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS(CURRENT_USER_ID),
),
).toContain('"waiting":true');
});
it('starts collapsed from persisted state and re-expands without refetching', async () => {
localStorage.setItem(
LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS(CURRENT_USER_ID),
JSON.stringify({ waiting: true, authored: false }),
);
const { getAllByTestId, queryAllByTestId, emitted } = renderComponent({
props: openProps([{ items: [makeItem()] }, {}]),
});
expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0);
await fireEvent.click(getAllByTestId('workflow-review-section-header')[0]);
expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(1);
expect(emitted().loadMore).toBeUndefined();
});
});
describe('load more', () => {
it('renders the button only for the section that has more, and emits its key', async () => {
const { getAllByTestId, emitted } = renderComponent({
props: openProps([
{ items: [makeItem()] },
{ items: [makeItem({ id: 'req-2' })], hasMore: true },
]),
});
const buttons = getAllByTestId('workflow-review-section-load-more');
expect(buttons).toHaveLength(1);
expect(buttons[0].dataset.section).toBe('authored');
await fireEvent.click(buttons[0]);
expect(emitted().loadMore).toEqual([['authored']]);
});
it('keeps the sentinel instead of a button on the closed tab', async () => {
const { queryAllByTestId } = renderComponent({
props: closedProps({ items: [makeItem({ state: 'closed' })], hasMore: true }),
});
await nextTick();
expect(queryAllByTestId('workflow-review-section-load-more')).toHaveLength(0);
expect(observe).toHaveBeenCalled();
});
it('does not observe a sentinel on the open tab', async () => {
renderComponent({
props: openProps([{ items: [makeItem()], hasMore: true }, {}]),
});
await nextTick();
expect(observe).not.toHaveBeenCalled();
});
});
describe('errors', () => {
it('shows one retry when an initial failure leaves no usable rows', async () => {
const { getByTestId, queryAllByTestId, emitted } = renderComponent({
props: {
...openProps([{ error: new Error('boom') }, {}]),
initialLoadFailed: true,
},
});
expect(getByTestId('workflow-review-list-error')).toHaveTextContent("Couldn't load reviews");
expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0);
await fireEvent.click(getByTestId('workflow-review-list-retry'));
expect(emitted().retryActiveTab).toEqual([[]]);
});
it('shows a healthy sibling and gives the failed section its own retry', async () => {
const { getByTestId, getAllByTestId, queryByTestId, emitted } = renderComponent({
props: {
...openProps([{ error: new Error('boom') }, { items: [makeItem()] }]),
initialLoadFailed: true,
},
});
expect(queryByTestId('workflow-review-list-error')).not.toBeInTheDocument();
expect(getAllByTestId('workflow-review-request-row')).toHaveLength(1);
expect(getByTestId('workflow-review-section-error').dataset.section).toBe('waiting');
await fireEvent.click(getByTestId('workflow-review-section-retry'));
expect(emitted().retry).toEqual([['waiting']]);
});
it('keeps loaded rows and the sibling section visible and emits retry for that section', async () => {
const { getByTestId, getAllByTestId, emitted } = renderComponent({
props: openProps([
{ items: [makeItem()], error: new Error('boom') },
{ items: [makeItem({ id: 'req-2' })] },
]),
});
expect(getAllByTestId('workflow-review-request-row')).toHaveLength(2);
expect(getByTestId('workflow-review-section-error').dataset.section).toBe('waiting');
await fireEvent.click(getByTestId('workflow-review-section-retry'));
expect(emitted().retry).toEqual([['waiting']]);
});
});
});

View File

@ -1,527 +0,0 @@
<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,
N8nButton,
N8nCard,
N8nHeading,
N8nIcon,
N8nLoading,
N8nTabs,
N8nText,
} from '@n8n/design-system';
import { useUsersStore } from '@n8n/stores/users.store';
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
import TimeAgo from '@/app/components/TimeAgo.vue';
import WorkflowReviewStatusDot from './WorkflowReviewStatusDot.vue';
import {
useReviewInboxSectionCollapse,
type CollapsibleReviewInboxSection,
} from '../composables/useReviewInboxSectionCollapse';
import type { ReviewInboxSectionKey } from '../reviewInbox.store';
/** One independently paginated list, flattened from its store slice. */
export type ReviewInboxSidebarSection = {
key: ReviewInboxSectionKey;
items: WorkflowReviewInboxItem[];
loadingMore: boolean;
hasMore: boolean;
error: Error | null;
};
const props = defineProps<{
sections: ReviewInboxSidebarSection[];
loading: boolean;
initialLoadFailed: boolean;
activeTab: WorkflowReviewRequestState;
openCount: number | null;
closedCount: number | null;
selectedId: string | null;
}>();
const emit = defineEmits<{
select: [id: string];
clear: [];
'update:activeTab': [tab: WorkflowReviewRequestState];
loadMore: [section: ReviewInboxSectionKey];
retry: [section: ReviewInboxSectionKey];
retryActiveTab: [];
}>();
const i18n = useI18n();
const usersStore = useUsersStore();
const { isCollapsed, toggleSection } = useReviewInboxSectionCollapse();
/**
* Admins see every review, including ones nobody assigned them, so "Waiting for
* your review" would wrongly claim they are blocking.
*/
const usesImpersonalWaitingLabels = computed(() => usersStore.isAdminOrOwner);
function sectionTitle(key: CollapsibleReviewInboxSection): string {
return key === 'waiting' && usesImpersonalWaitingLabels.value
? i18n.baseText('workflowReviews.sidebar.section.waiting.titleAdmin')
: i18n.baseText(`workflowReviews.sidebar.section.${key}.title`);
}
const listRef = ref<HTMLElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null);
function countTag(count: number | null): string | undefined {
return count === null ? undefined : String(count);
}
const tabOptions = computed(() => [
{
label: i18n.baseText('workflowReviews.sidebar.tabs.open'),
value: 'open' as const,
tag: countTag(props.openCount),
},
{
label: i18n.baseText('workflowReviews.sidebar.tabs.closed'),
value: 'closed' as const,
tag: countTag(props.closedCount),
},
]);
function isCollapsibleSection(key: ReviewInboxSectionKey): key is CollapsibleReviewInboxSection {
return key !== 'closed';
}
const hasUsableRows = computed(() => props.sections.some((section) => section.items.length > 0));
const showInitialLoadError = computed(() => props.initialLoadFailed && !hasUsableRows.value);
const groups = computed(() =>
props.sections
.map((section) => {
const collapsibleKey = isCollapsibleSection(section.key) ? section.key : null;
return {
key: section.key,
section,
collapsible: collapsibleKey !== null,
title: collapsibleKey ? sectionTitle(collapsibleKey) : null,
collapsed: collapsibleKey !== null && isCollapsed(collapsibleKey),
headerId: `workflow-review-section-header-${section.key}`,
groupId: `workflow-review-section-group-${section.key}`,
// A settled, empty section is dropped entirely. When another section
// has rows, an error stays visible with its section-specific retry.
visible: section.error !== null || section.items.length > 0 || section.hasMore,
};
})
.filter((group) => group.visible && !props.loading && !showInitialLoadError.value),
);
/** The closed tab keeps infinite scroll; the open tab loads more explicitly. */
const closedSentinelActive = computed(
() => props.sections.length === 1 && props.sections[0].key === 'closed',
);
const { observe: observeForLoadMore } = useIntersectionObserver({
root: listRef,
threshold: 0.01,
onIntersect: () => emit('loadMore', 'closed'),
});
watch(
[
loadMoreSentinel,
closedSentinelActive,
() => props.sections[0]?.hasMore,
() => props.sections[0]?.loadingMore,
() => props.sections[0]?.items.length,
],
([sentinel, sentinelActive, hasMore, loadingMore]) => {
if (sentinel && sentinelActive && hasMore && !loadingMore) {
observeForLoadMore(sentinel);
}
},
{ immediate: true, flush: 'post' },
);
function onTabChange(value: string | number | boolean) {
emit('update:activeTab', String(value) as WorkflowReviewRequestState);
}
function onSectionHeaderClick(key: ReviewInboxSectionKey) {
if (isCollapsibleSection(key)) toggleSection(key);
}
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="activeTab"
:options="tabOptions"
variant="modern"
data-test-id="workflow-reviews-tabs"
@update:model-value="onTabChange"
/>
</div>
<div ref="listRef" :class="$style.list" @click.self="onListBackgroundClick">
<N8nLoading
v-if="loading"
:loading="true"
:rows="3"
data-test-id="workflow-review-list-skeleton"
/>
<div
v-else-if="showInitialLoadError"
:class="$style.sectionError"
data-test-id="workflow-review-list-error"
>
<N8nText color="danger" size="small">
{{ i18n.baseText('workflowReviews.sidebar.error') }}
</N8nText>
<N8nButton
variant="outline"
size="mini"
:label="i18n.baseText('generic.retry')"
data-test-id="workflow-review-list-retry"
@click="emit('retryActiveTab')"
/>
</div>
<div v-for="group in groups" :key="group.key" :class="$style.section">
<button
v-if="group.title"
:id="group.headerId"
type="button"
:class="$style.sectionHeader"
:aria-expanded="!group.collapsed"
:aria-controls="group.groupId"
:data-section="group.key"
data-test-id="workflow-review-section-header"
@click="onSectionHeaderClick(group.key)"
>
<N8nIcon
icon="chevron-down"
size="small"
:class="[$style.chevron, { [$style.chevronCollapsed]: group.collapsed }]"
/>
<N8nText bold size="small" color="text-base">{{ group.title }}</N8nText>
</button>
<div
:id="group.groupId"
role="listbox"
:class="$style.group"
:aria-labelledby="group.title ? group.headerId : undefined"
:aria-label="
group.title ? undefined : i18n.baseText('workflowReviews.sidebar.tabs.closed')
"
>
<template v-if="!group.collapsed">
<N8nCard
v-for="item in group.section.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">
<div :class="$style.cardHeader">
<N8nText bold tag="h3" :class="$style.cardTitle">
{{ item.title }}
</N8nText>
<WorkflowReviewStatusDot :state="item.state" :decision="item.decision" />
</div>
<div :class="$style.cardMeta">
<N8nBadge
v-if="item.workflowName"
variant="outline"
: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>
<div :class="$style.cardMetaActions">
<N8nText
size="xsmall"
color="text-light"
:class="$style.cardMetaTime"
data-test-id="workflow-review-request-created-at"
>
<TimeAgo :date="item.createdAt" />
</N8nText>
</div>
</div>
</div>
</N8nCard>
</template>
</div>
<template v-if="!group.collapsed">
<div v-if="group.section.loadingMore" :class="$style.loadingMore">
<N8nLoading :loading="true" :rows="1" />
</div>
<div
v-if="group.section.error"
:class="$style.sectionError"
:data-section="group.key"
data-test-id="workflow-review-section-error"
>
<N8nText color="danger" size="small">
{{ i18n.baseText('workflowReviews.sidebar.error') }}
</N8nText>
<N8nButton
variant="outline"
size="mini"
:label="i18n.baseText('generic.retry')"
:data-section="group.key"
data-test-id="workflow-review-section-retry"
@click="emit('retry', group.key)"
/>
</div>
<N8nButton
v-if="group.collapsible && group.section.hasMore"
variant="outline"
size="small"
:class="$style.loadMoreButton"
:label="i18n.baseText('workflowReviews.sidebar.loadMore')"
:loading="group.section.loadingMore"
:data-section="group.key"
data-test-id="workflow-review-section-load-more"
@click="emit('loadMore', group.key)"
/>
</template>
</div>
<!-- Outside the v-for: a ref inside one would resolve to an array. -->
<div
v-if="closedSentinelActive && !loading && !showInitialLoadError"
ref="loadMoreSentinel"
:class="$style.sentinel"
/>
</div>
</aside>
</template>
<style lang="scss" module>
.sidebar {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
border-right: var(--border-width) solid var(--border-color);
}
.columnTitle {
display: flex;
align-items: center;
min-height: var(--spacing--2xl);
padding: 0 var(--spacing--md) var(--spacing--sm) 0;
}
.header {
position: relative;
display: flex;
align-items: center;
height: var(--review-tab-bar--height, var(--height--sm));
padding-right: var(--spacing--md);
margin-bottom: var(--review-tab-bar--gap, calc(var(--spacing--sm) + 15px));
&::after {
content: '';
position: absolute;
left: 0;
right: var(--spacing--md);
bottom: calc(-1 * var(--review-tab-bar--indicator-overhang) - var(--border-width));
border-bottom: var(--border-width) solid var(--border-color);
}
}
.list {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing--sm);
min-width: 0;
overflow-y: auto;
padding: 0 var(--spacing--md) var(--spacing--md) 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
min-width: 0;
}
.group {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
min-width: 0;
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
width: 100%;
padding: var(--spacing--3xs) var(--spacing--2xs);
border: none;
border-radius: var(--radius);
background: transparent;
cursor: pointer;
text-align: left;
&:hover {
background-color: var(--background--active);
}
&:focus-visible {
outline: var(--border-width) solid var(--focus--border-color);
}
}
.chevron {
transition: transform 0.2s ease;
color: var(--color--text--tint-1);
}
.chevronCollapsed {
transform: rotate(-90deg);
}
.sectionError {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--2xs);
}
.card {
cursor: pointer;
padding: var(--spacing--xs);
align-items: stretch;
border: var(--border-width) solid var(--border-color);
&:hover:not(.cardSelected) {
background-color: var(--background--hover);
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%;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
width: 100%;
min-width: 0;
}
.cardTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-size: var(--font-size--sm);
}
.cardMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
width: 100%;
min-width: 0;
}
.cardMetaActions {
display: flex;
align-items: center;
gap: var(--spacing--sm);
margin-left: auto;
flex-shrink: 0;
}
.cardMetaTime {
white-space: nowrap;
}
.workflowBadge {
flex: 0 1 auto;
min-width: 0;
border: var(--border);
border-radius: var(--radius);
padding: var(--spacing--4xs) var(--spacing--2xs);
color: var(--color--text);
> 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) + var(--border-width));
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
}
.loadMoreButton {
align-self: center;
}
.loadingMore {
padding: var(--spacing--sm);
}
.sentinel {
height: var(--border-width);
}
</style>

View File

@ -12,7 +12,7 @@ const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/reviews/:reviewRequestId?',
path: '/inbox/reviews/:reviewId',
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
component: { template: '<div />' },
},
@ -44,7 +44,7 @@ describe('WorkflowReviewSubmittedDialog', () => {
expect(getByRole('link', { name: 'your submission' })).toHaveAttribute(
'href',
'/reviews/review-1',
'/inbox/reviews/review-1',
);
});

View File

@ -42,7 +42,7 @@ const { submittedDialogDismissed } = useWorkflowReviewDialogPreferences();
<N8nLink
:to="{
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
params: { reviewRequestId: workflowReviewRequestId },
params: { reviewId: workflowReviewRequestId },
}"
>
{{ i18n.baseText('workflowReviews.submitted.description.submission') }}

View File

@ -50,7 +50,7 @@ const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/reviews/:reviewRequestId?',
path: '/inbox/reviews/:reviewId',
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
component: { template: '<div />' },
},
@ -129,7 +129,10 @@ describe('WorkflowUpdateReviewDialog', () => {
it('links to the open review', async () => {
const { getByRole } = await renderDialog();
expect(getByRole('link', { name: 'open review' })).toHaveAttribute('href', '/reviews/review-1');
expect(getByRole('link', { name: 'open review' })).toHaveAttribute(
'href',
'/inbox/reviews/review-1',
);
});
it('stays open without prefilling when no open review is known yet', async () => {

View File

@ -257,7 +257,7 @@ const submit = async () => {
v-if="workflowReviewRequestId"
:to="{
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
params: { reviewRequestId: workflowReviewRequestId },
params: { reviewId: workflowReviewRequestId },
}"
>
{{ i18n.baseText('workflowReviews.updateReview.description.review') }}

View File

@ -1,42 +1,4 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue';
export const WORKFLOW_REVIEW_REQUESTS_VIEW = 'WorkflowReviewRequestsView';
/** What a feed entry may say about a linked workflow: its live name and its pinned version. */
export type ReviewLinkedWorkflowContext = {
workflowName: string;
pinnedVersionId: string | null;
/** The user-given version name, e.g. "Release candidate"; null when never named or pruned. */
pinnedVersionName: string | null;
};
/**
* The review's linked workflows, keyed by workflow id. Provided by the detail surface, read
* by feed entries that mention a workflow. Resolved at read time on purpose: feed payloads
* are immutable and never snapshot names, so a rename shows up everywhere at once. A workflow
* deleted since the entry was written has no row here; the entry copy degrades.
*/
export const ReviewLinkedWorkflowsKey: InjectionKey<
ComputedRef<Map<string, ReviewLinkedWorkflowContext>>
> = Symbol('reviewLinkedWorkflows');
/** The detail pane's scroll area, which the activity feed scrolls in. */
export const ReviewDetailScrollContainerKey: InjectionKey<Readonly<Ref<HTMLElement | null>>> =
Symbol('reviewDetailScrollContainer');
/**
* Routing contract for the review inbox.
*
* Path: /reviews/:reviewRequestId?
* - `:reviewRequestId` is the open review (deep-linkable). Absent = inbox
* with nothing selected. Selection always navigates via router.replace.
*
* Query params hold filter state; adding a filter must not require a routing
* change — reserve the key here, hydrate it in the view, and write it with
* router.replace({ query }) preserving params.
* - `state`: 'open' | 'closed'. Default 'open' is omitted from the URL.
* - `tab`: 'activity' | 'changes' — detail-pane tab. Default 'activity' is
* omitted from the URL.
* - Reserved for later filters: `q`, `projectId`, `author`, `reviewer`.
*/
export const REVIEW_INBOX_QUERY_PARAM = { state: 'state', tab: 'tab' } as const;
export {
WORKFLOW_REVIEW_REQUESTS_VIEW,
REVIEW_INBOX_QUERY_PARAM,
} from '@n8n/frontend-module-inbox';

View File

@ -1,34 +0,0 @@
import { defineFrontendModule } 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 = defineFrontendModule({
id: 'workflow-reviews',
name: 'Workflow Reviews',
description: 'Cross-project workflow review inbox.',
icon: 'message-square-text',
routes: [
{
path: '/reviews/:reviewRequestId?',
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
component: WorkflowReviewRequestsView,
beforeEnter() {
return (
useWorkflowReviewsFeature().isWorkflowReviewsEnabled.value || {
name: VIEWS.HOMEPAGE,
}
);
},
meta: {
layout: 'default',
middleware: ['authenticated', 'custom'],
},
},
],
});

View File

@ -1,800 +0,0 @@
import type {
ListWorkflowReviewInboxResponse,
WorkflowReviewInboxItem,
WorkflowReviewRequestDetail,
} from '@n8n/api-types';
import { createPinia, setActivePinia } from 'pinia';
import { ResponseError } from '@n8n/rest-api-client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as workflowReviewsApi from './workflowReviews.api';
import { useReviewInboxStore } from './reviewInbox.store';
vi.mock('./workflowReviews.api');
type SectionKey = 'waiting' | 'authored' | 'closed';
function makeItem(overrides: Partial<WorkflowReviewInboxItem> = {}): WorkflowReviewInboxItem {
return {
id: 'req-1',
projectId: 'proj-1',
title: 'Review',
workflowName: 'My workflow',
workflowVersionId: null,
requester: null,
authors: [],
reviewers: [],
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
...overrides,
};
}
function page(
data: WorkflowReviewInboxItem[],
overrides: Partial<ListWorkflowReviewInboxResponse> = {},
): ListWorkflowReviewInboxResponse {
return { data, nextCursor: null, hasMore: false, ...overrides };
}
const emptyPage = () => page([]);
/** Route each mocked inbox request to the section it belongs to. */
function sectionOf(params: workflowReviewsApi.FetchWorkflowReviewInboxParams): SectionKey {
if (params.state === 'closed') return 'closed';
return params.category === 'authored' ? 'authored' : 'waiting';
}
function mockInbox(
responses: Partial<
Record<
SectionKey,
ListWorkflowReviewInboxResponse | (() => Promise<ListWorkflowReviewInboxResponse>)
>
>,
) {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockImplementation(
async (_context, params) => {
const response = responses[sectionOf(params)];
if (typeof response === 'function') return await response();
return response ?? emptyPage();
},
);
}
function mockSummary(open: number, closed: number) {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({ open, closed });
}
function inboxCallsFor(section: SectionKey) {
return vi
.mocked(workflowReviewsApi.fetchWorkflowReviewInbox)
.mock.calls.filter(([, params]) => sectionOf(params) === section);
}
describe('useReviewInboxStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
});
describe('summary and section fetching', () => {
it('loads the summary and both open sections', async () => {
mockSummary(3, 12);
mockInbox({
waiting: page([makeItem({ id: 'waiting-1' })]),
authored: page([makeItem({ id: 'authored-1' })]),
});
const store = useReviewInboxStore();
await Promise.all([store.fetchSummary(), store.fetchActiveTab()]);
expect(workflowReviewsApi.fetchWorkflowReviewInboxSummary).toHaveBeenCalledTimes(1);
expect(store.openCount).toBe(3);
expect(store.closedCount).toBe(12);
expect(inboxCallsFor('waiting')).toHaveLength(1);
expect(inboxCallsFor('authored')).toHaveLength(1);
expect(inboxCallsFor('closed')).toHaveLength(0);
expect(store.sections.waiting.items).toEqual([expect.objectContaining({ id: 'waiting-1' })]);
expect(store.sections.authored.items).toEqual([
expect.objectContaining({ id: 'authored-1' }),
]);
});
it('requests the closed tab without a category', async () => {
mockInbox({ closed: page([makeItem({ id: 'closed-1', state: 'closed' })]) });
const store = useReviewInboxStore();
await store.setActiveTab('closed');
expect(store.activeTab).toBe('closed');
expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenCalledTimes(1);
expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ state: 'closed', category: undefined }),
);
expect(store.sections.closed.items).toHaveLength(1);
});
it('fetches the active tab without waiting for the summary', async () => {
let resolveSummary!: (value: { open: number; closed: number }) => void;
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockImplementationOnce(
async () =>
await new Promise<{ open: number; closed: number }>((resolve) => {
resolveSummary = resolve;
}),
);
mockInbox({});
const store = useReviewInboxStore();
const summary = store.fetchSummary();
await store.fetchActiveTab();
expect(inboxCallsFor('waiting')).toHaveLength(1);
expect(inboxCallsFor('authored')).toHaveLength(1);
expect(store.isEmpty).toBe(true);
resolveSummary({ open: 0, closed: 0 });
await summary;
});
it('stores each section as soon as its own request settles', async () => {
let resolveAuthored!: (value: ListWorkflowReviewInboxResponse) => void;
mockInbox({
waiting: page([makeItem({ id: 'waiting-1' })]),
authored: async () =>
await new Promise<ListWorkflowReviewInboxResponse>((resolve) => {
resolveAuthored = resolve;
}),
});
const store = useReviewInboxStore();
const fetching = store.fetchActiveTab();
await vi.waitFor(() => {
expect(store.sections.waiting.items).toHaveLength(1);
});
expect(store.sections.waiting.loading).toBe(false);
expect(store.sections.authored.loading).toBe(true);
expect(store.isLoadingActiveTab).toBe(true);
resolveAuthored(page([makeItem({ id: 'authored-1' })]));
await fetching;
expect(store.sections.authored.items).toHaveLength(1);
});
it('does not apply summary results after reset', async () => {
let resolveSummary!: (value: { open: number; closed: number }) => void;
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockImplementationOnce(
async () =>
await new Promise<{ open: number; closed: number }>((resolve) => {
resolveSummary = resolve;
}),
);
const store = useReviewInboxStore();
const summary = store.fetchSummary();
store.reset();
resolveSummary({ open: 1, closed: 0 });
await summary;
expect(store.openCount).toBeNull();
expect(store.closedCount).toBeNull();
});
it('ignores a stale response from an earlier fetch of the same section', async () => {
let resolveFirst!: (value: ListWorkflowReviewInboxResponse) => void;
let waitingCalls = 0;
mockInbox({
waiting: async () => {
waitingCalls += 1;
if (waitingCalls === 1) {
return await new Promise<ListWorkflowReviewInboxResponse>((resolve) => {
resolveFirst = resolve;
});
}
return page([makeItem({ id: 'fresh' })]);
},
});
const store = useReviewInboxStore();
const firstFetch = store.fetchActiveTab();
await store.fetchActiveTab();
expect(store.sections.waiting.items).toEqual([expect.objectContaining({ id: 'fresh' })]);
resolveFirst(page([makeItem({ id: 'stale' })]));
await firstFetch;
expect(store.sections.waiting.items).toEqual([expect.objectContaining({ id: 'fresh' })]);
});
});
describe('pagination', () => {
it('paginates each section with its own cursor', async () => {
mockInbox({
waiting: page([makeItem({ id: 'waiting-1' })], {
nextCursor: 'waiting-cursor',
hasMore: true,
}),
authored: page([makeItem({ id: 'authored-1' })], {
nextCursor: 'authored-cursor',
hasMore: true,
}),
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
mockInbox({
waiting: page([makeItem({ id: 'waiting-2' })]),
authored: page([makeItem({ id: 'authored-2' })]),
});
await store.loadMore('waiting');
expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ category: 'waiting', cursor: 'waiting-cursor' }),
);
expect(store.sections.waiting.items.map((item) => item.id)).toEqual([
'waiting-1',
'waiting-2',
]);
// Untouched: load-more appends to one section only.
expect(store.sections.authored.items.map((item) => item.id)).toEqual(['authored-1']);
expect(store.sections.authored.hasMore).toBe(true);
});
it('ignores a duplicate load-more while one is in flight', async () => {
mockInbox({
waiting: page([makeItem({ id: 'waiting-1' })], { nextCursor: 'cursor', hasMore: true }),
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
let resolveMore!: (value: ListWorkflowReviewInboxResponse) => void;
mockInbox({
waiting: async () =>
await new Promise<ListWorkflowReviewInboxResponse>((resolve) => {
resolveMore = resolve;
}),
});
const first = store.loadMore('waiting');
await store.loadMore('waiting');
expect(inboxCallsFor('waiting')).toHaveLength(2);
resolveMore(page([makeItem({ id: 'waiting-2' })]));
await first;
expect(store.sections.waiting.items).toHaveLength(2);
});
it('does nothing when the section has no further pages', async () => {
mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]) });
const store = useReviewInboxStore();
await store.fetchActiveTab();
await store.loadMore('waiting');
expect(inboxCallsFor('waiting')).toHaveLength(1);
});
it('clears loadingMore when a refetch invalidates pagination', async () => {
mockInbox({
waiting: page([makeItem({ id: 'waiting-1' })], { nextCursor: 'cursor', hasMore: true }),
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
let resolveMore!: (value: ListWorkflowReviewInboxResponse) => void;
mockInbox({
waiting: async () =>
await new Promise<ListWorkflowReviewInboxResponse>((resolve) => {
resolveMore = resolve;
}),
});
const loadMore = store.loadMore('waiting');
await vi.waitFor(() => {
expect(store.sections.waiting.loadingMore).toBe(true);
});
mockInbox({ closed: emptyPage() });
await store.setActiveTab('closed');
// setActiveTab only refetches the closed slice, so reset the stalled one directly.
mockInbox({ waiting: emptyPage() });
await store.setActiveTab('open');
expect(store.sections.waiting.loadingMore).toBe(false);
resolveMore(page([makeItem({ id: 'never-applied' })]));
await loadMore;
expect(store.sections.waiting.loadingMore).toBe(false);
expect(store.sections.waiting.items).toEqual([]);
});
});
describe('errors', () => {
it('settles the active tab with one initial error when a section fails', async () => {
mockInbox({
waiting: async () => {
throw new Error('network');
},
authored: page([makeItem({ id: 'authored-1' })]),
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
expect(store.sections.waiting.error).toEqual(new Error('network'));
expect(store.sections.authored.error).toBeNull();
expect(store.sections.authored.items).toHaveLength(1);
expect(store.activeTabInitialLoadFailed).toBe(true);
});
it('keeps rows and the cursor after a failed load-more and retries the same page', async () => {
mockInbox({
waiting: page([makeItem({ id: 'waiting-1' })], { nextCursor: 'cursor', hasMore: true }),
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
mockInbox({
waiting: async () => {
throw new Error('boom');
},
});
await store.loadMore('waiting');
expect(store.sections.waiting.items).toHaveLength(1);
expect(store.sections.waiting.nextCursor).toBe('cursor');
expect(store.sections.waiting.error).toEqual(new Error('boom'));
mockInbox({ waiting: page([makeItem({ id: 'waiting-2' })]) });
await store.retry('waiting');
expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ category: 'waiting', cursor: 'cursor' }),
);
expect(store.sections.waiting.items.map((item) => item.id)).toEqual([
'waiting-1',
'waiting-2',
]);
expect(store.sections.waiting.error).toBeNull();
});
it('retries the first page when the initial section fetch failed', async () => {
mockInbox({
waiting: async () => {
throw new Error('down');
},
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]) });
await store.retry('waiting');
expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ category: 'waiting', cursor: undefined }),
);
expect(store.sections.waiting.items).toHaveLength(1);
});
it('does not treat a failed section fetch as an empty inbox', async () => {
mockInbox({
waiting: async () => {
throw new Error('network');
},
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
expect(store.isEmpty).toBe(false);
// The failure belongs to the section that suffered it, and to it alone.
expect(store.sections.waiting.error).toEqual(new Error('network'));
expect(store.sections.authored.error).toBeNull();
});
it('leaves counts unknown when the summary fails', async () => {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockRejectedValue(
new Error('summary down'),
);
const store = useReviewInboxStore();
await store.fetchSummary();
expect(store.openCount).toBeNull();
expect(store.closedCount).toBeNull();
});
it('loads sections independently when the summary fails', async () => {
mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]), authored: emptyPage() });
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockRejectedValue(
new Error('summary down'),
);
const store = useReviewInboxStore();
await Promise.all([store.fetchSummary(), store.fetchActiveTab()]);
expect(store.sections.waiting.items).toHaveLength(1);
expect(store.isEmpty).toBe(false);
expect(store.openCount).toBeNull();
expect(store.closedCount).toBeNull();
});
});
describe('isEmpty', () => {
it('is true on the open tab only when both sections are empty', async () => {
mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]), authored: emptyPage() });
const store = useReviewInboxStore();
await store.fetchActiveTab();
expect(store.isEmpty).toBe(false);
mockInbox({ waiting: emptyPage(), authored: emptyPage() });
await store.fetchActiveTab();
expect(store.isEmpty).toBe(true);
});
it('follows the closed slice on the closed tab', async () => {
mockInbox({ closed: emptyPage() });
const store = useReviewInboxStore();
await store.setActiveTab('closed');
expect(store.isEmpty).toBe(true);
});
});
describe('active tab state', () => {
it('reports rows from either open section', async () => {
mockInbox({ waiting: emptyPage(), authored: page([makeItem({ id: 'authored-1' })]) });
const store = useReviewInboxStore();
await store.fetchActiveTab();
expect(store.hasItemsInActiveTab).toBe(true);
expect(store.isEmpty).toBe(false);
expect(store.isLoadingActiveTab).toBe(false);
});
it('reports one initial failure once both sections have settled', async () => {
mockInbox({
waiting: async () => {
throw new Error('network');
},
authored: emptyPage(),
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
expect(store.isLoadingActiveTab).toBe(false);
expect(store.isEmpty).toBe(false);
expect(store.hasItemsInActiveTab).toBe(false);
expect(store.activeTabInitialLoadFailed).toBe(true);
});
it('loads until both open sections have settled', async () => {
let releaseWaiting = () => {};
mockInbox({
waiting: async () => {
await new Promise<void>((resolve) => {
releaseWaiting = resolve;
});
return { data: [], nextCursor: null, hasMore: false };
},
authored: emptyPage(),
});
const store = useReviewInboxStore();
const fetching = store.fetchActiveTab();
await vi.waitFor(() => expect(store.isLoadingActiveTab).toBe(true));
expect(store.isEmpty).toBe(false);
releaseWaiting();
await fetching;
expect(store.isLoadingActiveTab).toBe(false);
expect(store.isEmpty).toBe(true);
});
});
describe('decideOnReview', () => {
async function seedOpenSections() {
mockSummary(2, 5);
mockInbox({
waiting: page([makeItem({ id: 'req-1' })]),
authored: page([makeItem({ id: 'req-2', title: 'Mine' })]),
});
const store = useReviewInboxStore();
await Promise.all([store.fetchSummary(), store.fetchActiveTab()]);
return store;
}
it('removes an approved item from its section, adjusts counts, and patches detail', async () => {
const store = await seedOpenSections();
store.detail = createDetail();
vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockResolvedValue({
id: 'req-1',
state: 'closed',
decision: 'approved',
workflowVersionId: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
});
await store.decideOnReview('req-1', { decision: 'approved' });
expect(store.sections.waiting.items).toEqual([]);
expect(store.sections.authored.items).toHaveLength(1);
expect(store.openCount).toBe(1);
expect(store.closedCount).toBe(6);
expect(store.detail).toEqual(
expect.objectContaining({ state: 'closed', decision: 'approved' }),
);
});
it('decides an authored review without moving it between sections', async () => {
const store = await seedOpenSections();
vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockResolvedValue({
id: 'req-2',
state: 'open',
decision: 'changes_requested',
workflowVersionId: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
});
await store.decideOnReview('req-2', {
decision: 'changes_requested',
note: 'needs work',
});
expect(workflowReviewsApi.decideWorkflowReviewRequest).toHaveBeenCalledWith(
expect.anything(),
'req-2',
{ decision: 'changes_requested', note: 'needs work' },
);
expect(store.sections.waiting.items.map((item) => item.id)).toEqual(['req-1']);
expect(store.sections.authored.items).toEqual([
expect.objectContaining({ id: 'req-2', state: 'open', decision: 'changes_requested' }),
]);
expect(store.openCount).toBe(2);
expect(store.closedCount).toBe(5);
});
it('returns the response so callers can surface the auto-publish outcome', async () => {
const store = await seedOpenSections();
vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockResolvedValue({
id: 'req-1',
state: 'closed',
decision: 'approved',
workflowVersionId: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
autoPublish: { status: 'published' },
});
const response = await store.decideOnReview('req-1', { decision: 'approved' });
expect(response.autoPublish).toEqual({ status: 'published' });
});
it('rethrows an API error and leaves the state untouched', async () => {
const store = await seedOpenSections();
vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockRejectedValue(
new Error('forbidden'),
);
await expect(store.decideOnReview('req-1', { decision: 'approved' })).rejects.toThrow(
'forbidden',
);
expect(store.sections.waiting.items).toEqual([
expect.objectContaining({ state: 'open', decision: 'pending' }),
]);
expect(store.openCount).toBe(2);
expect(store.closedCount).toBe(5);
});
});
describe('findItemById', () => {
it('resolves items from either open section and returns null otherwise', async () => {
mockInbox({
waiting: page([makeItem({ id: 'req-1' })]),
authored: page([makeItem({ id: 'req-2' })]),
});
const store = useReviewInboxStore();
await store.fetchActiveTab();
expect(store.findItemById('req-1')).toEqual(expect.objectContaining({ id: 'req-1' }));
expect(store.findItemById('req-2')).toEqual(expect.objectContaining({ id: 'req-2' }));
expect(store.findItemById('missing')).toBeNull();
});
});
describe('detail', () => {
it('loads review detail', async () => {
const expected = createDetail();
vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockResolvedValue(expected);
const store = useReviewInboxStore();
await store.fetchDetail('req-1');
expect(store.detail).toEqual(expected);
expect(store.detailLoading).toBe(false);
expect(store.detailNotFound).toBe(false);
});
it('shows not found without throwing for a 404', async () => {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockRejectedValue(
new ResponseError('gone', { httpStatusCode: 404 }),
);
const store = useReviewInboxStore();
await expect(store.fetchDetail('missing')).resolves.toBeUndefined();
expect(store.detail).toBeNull();
expect(store.detailNotFound).toBe(true);
expect(store.detailLoading).toBe(false);
});
it('rethrows a non-404 detail error', async () => {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockRejectedValue(
new ResponseError('forbidden', { httpStatusCode: 403 }),
);
const store = useReviewInboxStore();
await expect(store.fetchDetail('req-1')).rejects.toThrow('forbidden');
expect(store.detailNotFound).toBe(false);
});
it('does not suppress the section empty states after a failed detail fetch', async () => {
mockInbox({ waiting: emptyPage(), authored: emptyPage() });
vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockRejectedValue(
new ResponseError('boom', { httpStatusCode: 500 }),
);
const store = useReviewInboxStore();
await store.fetchActiveTab();
await expect(store.fetchDetail('req-1')).rejects.toThrow('boom');
expect(store.isEmpty).toBe(true);
expect(store.sections.waiting.error).toBeNull();
});
it('ignores a stale detail response', async () => {
let resolveFirst!: (detail: WorkflowReviewRequestDetail) => void;
vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail)
.mockImplementationOnce(
async () =>
await new Promise<WorkflowReviewRequestDetail>((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce({ ...createDetail(), id: 'req-2', title: 'Newer review' });
const store = useReviewInboxStore();
const firstFetch = store.fetchDetail('req-1');
await store.fetchDetail('req-2');
resolveFirst(createDetail());
await firstFetch;
expect(store.detail).toEqual(expect.objectContaining({ id: 'req-2', title: 'Newer review' }));
});
it('keeps detail visible while refetching the same review', async () => {
const first = createDetail();
let resolveSecond!: (detail: WorkflowReviewRequestDetail) => void;
vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail)
.mockResolvedValueOnce(first)
.mockImplementationOnce(
async () =>
await new Promise<WorkflowReviewRequestDetail>((resolve) => {
resolveSecond = resolve;
}),
);
const store = useReviewInboxStore();
await store.fetchDetail('req-1');
const secondFetch = store.fetchDetail('req-1');
expect(store.detail).toEqual(first);
expect(store.detailLoading).toBe(false);
resolveSecond({ ...first, title: 'Updated title' });
await secondFetch;
expect(store.detail).toEqual(expect.objectContaining({ title: 'Updated title' }));
});
it('clears not found when a later refetch of the same review succeeds', async () => {
const detail = createDetail();
vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail)
.mockResolvedValueOnce(detail)
.mockRejectedValueOnce(new ResponseError('gone', { httpStatusCode: 404 }))
.mockResolvedValueOnce({ ...detail, title: 'Back again' });
const store = useReviewInboxStore();
await store.fetchDetail('req-1');
await store.fetchDetail('req-1');
expect(store.detailNotFound).toBe(true);
await store.fetchDetail('req-1');
expect(store.detail).toEqual(expect.objectContaining({ title: 'Back again' }));
expect(store.detailNotFound).toBe(false);
});
it('does not clear the detail when switching tabs', async () => {
mockInbox({ closed: emptyPage() });
const store = useReviewInboxStore();
store.detail = createDetail();
await store.setActiveTab('closed');
expect(store.detail).toEqual(expect.objectContaining({ id: 'req-1' }));
});
it('clears and resets detail state', () => {
const store = useReviewInboxStore();
store.detail = createDetail();
store.detailLoading = true;
store.detailNotFound = true;
store.clearDetail();
expect(store.detail).toBeNull();
expect(store.detailLoading).toBe(false);
expect(store.detailNotFound).toBe(false);
});
});
it('resets every section', async () => {
mockSummary(1, 1);
mockInbox({
waiting: page([makeItem({ id: 'req-1' })], { nextCursor: 'cursor', hasMore: true }),
authored: page([makeItem({ id: 'req-2' })]),
});
const store = useReviewInboxStore();
await Promise.all([store.fetchSummary(), store.fetchActiveTab()]);
store.reset();
expect(store.sections.waiting.items).toEqual([]);
expect(store.sections.waiting.nextCursor).toBeNull();
expect(store.sections.waiting.hasMore).toBe(false);
expect(store.sections.authored.items).toEqual([]);
expect(store.sections.closed.items).toEqual([]);
expect(store.activeTab).toBe('open');
expect(store.openCount).toBeNull();
expect(store.closedCount).toBeNull();
});
});
function createDetail(): WorkflowReviewRequestDetail {
return {
id: 'req-1',
projectId: 'proj-1',
title: 'Review',
requester: null,
authors: [],
reviewers: [],
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
description: null,
workflows: [],
viewerCanDecide: true,
viewerDecisionIneligibilityReason: null,
viewerCanComment: true,
};
}

View File

@ -1,388 +0,0 @@
import { defineStore } from 'pinia';
import type {
ListWorkflowReviewInboxResponse,
WorkflowReviewInboxCategory,
WorkflowReviewRequestDetail,
WorkflowReviewRequestState,
WorkflowReviewInboxItem,
} from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import { computed, ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import {
decideWorkflowReviewRequest,
fetchWorkflowReviewInbox,
fetchWorkflowReviewInboxSummary,
fetchWorkflowReviewRequestDetail,
type WorkflowReviewDecisionInput,
} from './workflowReviews.api';
import { toError } from './workflowReviews.utils';
const DEFAULT_LIMIT = 15;
/**
* The open tab is split by authorship, the closed tab stays flat — three
* independently paginated lists in total.
*/
export type ReviewInboxSectionKey = 'waiting' | 'authored' | 'closed';
type RequestPage = (cursor?: string) => Promise<ListWorkflowReviewInboxResponse>;
/** One keyset-paginated list with its own pagination and load-more state. */
function createInboxListSlice(requestPage: RequestPage) {
const items = ref<WorkflowReviewInboxItem[]>([]);
const nextCursor = ref<string | null>(null);
const hasMore = ref(false);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref<Error | null>(null);
// Which request failed, so `retry` repeats that one — a failed load-more keeps
// its rows and cursor, so retrying it must re-request the same page.
const failedRequest = ref<'list' | 'loadMore' | null>(null);
let requestSeq = 0;
function applyResponse(
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 fetchList() {
const seq = ++requestSeq;
items.value = [];
nextCursor.value = null;
hasMore.value = false;
// Invalidate any in-flight loadMore so pagination is not stuck.
loadingMore.value = false;
loading.value = true;
error.value = null;
failedRequest.value = null;
try {
const response = await requestPage();
if (seq !== requestSeq) return;
applyResponse(response, { append: false });
} catch (e) {
if (seq !== requestSeq) return;
error.value = toError(e);
failedRequest.value = 'list';
} finally {
if (seq === requestSeq) {
loading.value = false;
}
}
}
async function loadMore() {
if (loading.value || loadingMore.value || !hasMore.value || !nextCursor.value) {
return;
}
const seq = ++requestSeq;
const cursor = nextCursor.value;
loadingMore.value = true;
error.value = null;
failedRequest.value = null;
try {
const response = await requestPage(cursor);
if (seq !== requestSeq) return;
applyResponse(response, { append: true });
} catch (e) {
if (seq !== requestSeq) return;
// Rows and cursor are deliberately left in place, so the retry asks for
// the same page instead of restarting the section.
error.value = toError(e);
failedRequest.value = 'loadMore';
} finally {
if (seq === requestSeq) {
loadingMore.value = false;
}
}
}
async function retry() {
if (failedRequest.value === 'loadMore') {
await loadMore();
return;
}
await fetchList();
}
function findItem(id: string): WorkflowReviewInboxItem | null {
return items.value.find((candidate) => candidate.id === id) ?? null;
}
function removeItem(id: string): void {
items.value = items.value.filter((candidate) => candidate.id !== id);
}
function reset(): void {
requestSeq += 1;
items.value = [];
nextCursor.value = null;
hasMore.value = false;
loading.value = false;
loadingMore.value = false;
error.value = null;
failedRequest.value = null;
}
/** Settled with nothing to show — never "empty" while loading or after a failure. */
const isEmpty = computed(
() => !loading.value && error.value === null && items.value.length === 0,
);
const initialLoadFailed = computed(() => failedRequest.value === 'list');
return {
items,
nextCursor,
hasMore,
loading,
loadingMore,
error,
isEmpty,
initialLoadFailed,
fetchList,
loadMore,
retry,
findItem,
removeItem,
reset,
};
}
export const useReviewInboxStore = defineStore('workflowReviewInbox', () => {
const rootStore = useRootStore();
const openCount = ref<number | null>(null);
const closedCount = ref<number | null>(null);
const detail = ref<WorkflowReviewRequestDetail | null>(null);
const detailLoading = ref(false);
const detailNotFound = ref(false);
// The view hydrates this from `?state=` so the first list fetch uses the URL state.
const activeTab = ref<WorkflowReviewRequestState>('open');
let summaryRequestSeq = 0;
let detailRequestSeq = 0;
function requestPage(
state: WorkflowReviewRequestState,
category?: WorkflowReviewInboxCategory,
): RequestPage {
return async (cursor?: string) =>
await fetchWorkflowReviewInbox(rootStore.restApiContext, {
state,
category,
limit: DEFAULT_LIMIT,
cursor,
});
}
/**
* Plain object of slices, not a `ref`/`reactive` wrapper: the slice internals
* stay refs and Pinia unwraps them on access, so consumers read
* `store.sections.waiting.items` directly. `storeToRefs` does not reach into
* this object.
*/
const sections = {
waiting: createInboxListSlice(requestPage('open', 'waiting')),
authored: createInboxListSlice(requestPage('open', 'authored')),
closed: createInboxListSlice(requestPage('closed')),
};
const allSlices = [sections.waiting, sections.authored, sections.closed];
const activeSlices = computed(() =>
activeTab.value === 'closed' ? [sections.closed] : [sections.waiting, sections.authored],
);
const hasItemsInActiveTab = computed(() =>
activeSlices.value.some((slice) => slice.items.value.length > 0),
);
const isLoadingActiveTab = computed(() =>
activeSlices.value.some((slice) => slice.loading.value),
);
const activeTabInitialLoadFailed = computed(() =>
activeSlices.value.some((slice) => slice.initialLoadFailed.value),
);
// Settled with nothing to show. A failed slice is not treated as empty.
const isEmpty = computed(() => activeSlices.value.every((slice) => slice.isEmpty.value));
/**
* Both open sections start together and settle before the tab is rendered.
* Each slice still owns its pagination state after the initial load.
*/
async function fetchActiveTab() {
if (activeTab.value === 'closed') {
await sections.closed.fetchList();
return;
}
await Promise.all([sections.waiting.fetchList(), sections.authored.fetchList()]);
}
async function fetchSummary() {
const requestSeq = ++summaryRequestSeq;
try {
const summary = await fetchWorkflowReviewInboxSummary(rootStore.restApiContext);
if (requestSeq !== summaryRequestSeq) return;
openCount.value = summary.open;
closedCount.value = summary.closed;
} catch {
// Counts enhance the tabs and no-selection state, but never block the lists.
}
}
async function loadMore(section: ReviewInboxSectionKey) {
await sections[section].loadMore();
}
async function retry(section: ReviewInboxSectionKey) {
await sections[section].retry();
}
async function setActiveTab(tab: WorkflowReviewRequestState) {
if (activeTab.value === tab) return;
activeTab.value = tab;
await fetchActiveTab();
}
function findItemById(id: string): WorkflowReviewInboxItem | null {
for (const slice of allSlices) {
const item = slice.findItem(id);
if (item) return item;
}
return null;
}
async function fetchDetail(id: string) {
const requestSeq = ++detailRequestSeq;
const switchedReview = detail.value?.id !== id;
if (switchedReview) {
detail.value = null;
detailLoading.value = true;
detailNotFound.value = false;
}
try {
const response = await fetchWorkflowReviewRequestDetail(rootStore.restApiContext, id);
if (requestSeq !== detailRequestSeq) {
return;
}
detail.value = response;
detailNotFound.value = false;
} catch (e) {
if (requestSeq !== detailRequestSeq) {
return;
}
if (e instanceof ResponseError && e.httpStatusCode === 404) {
detailNotFound.value = true;
return;
}
// deliberately not stored on a list-scoped error ref: those gate the
// section empty states, so a detail failure would suppress them for the
// rest of the session.
throw e;
} finally {
if (requestSeq === detailRequestSeq) {
detailLoading.value = false;
}
}
}
function clearDetail() {
detailRequestSeq += 1;
detail.value = null;
detailLoading.value = false;
detailNotFound.value = false;
}
/**
* Submit a decision and patch the affected item in place. Approving closes
* the request; the closed tab refetches on activation and picks it up there.
* Decisions never change authorship, so an item never moves between the
* waiting and authored sections. Returns the response so callers can surface
* the auto-publish outcome.
*/
async function decideOnReview(id: string, input: WorkflowReviewDecisionInput) {
const summary = await decideWorkflowReviewRequest(rootStore.restApiContext, id, input);
const item = findItemById(id);
if (item) {
item.decision = summary.decision;
item.state = summary.state;
item.updatedAt = summary.updatedAt;
}
if (detail.value?.id === id) {
detail.value.decision = summary.decision;
detail.value.state = summary.state;
detail.value.updatedAt = summary.updatedAt;
}
if (summary.state === 'closed') {
if (openCount.value !== null) openCount.value = Math.max(0, openCount.value - 1);
if (closedCount.value !== null) closedCount.value += 1;
}
// The sections only show items matching the active tab filter.
if (item && item.state !== activeTab.value) {
for (const slice of allSlices) {
slice.removeItem(id);
}
}
return summary;
}
function reset() {
summaryRequestSeq += 1;
detailRequestSeq += 1;
openCount.value = null;
closedCount.value = null;
detail.value = null;
detailLoading.value = false;
detailNotFound.value = false;
activeTab.value = 'open';
for (const slice of allSlices) {
slice.reset();
}
}
return {
openCount,
closedCount,
sections,
detail,
detailLoading,
detailNotFound,
activeTab,
isEmpty,
isLoadingActiveTab,
activeTabInitialLoadFailed,
hasItemsInActiveTab,
fetchSummary,
fetchActiveTab,
loadMore,
retry,
setActiveTab,
findItemById,
fetchDetail,
clearDetail,
decideOnReview,
reset,
};
});

View File

@ -1,969 +0,0 @@
import type {
DecideWorkflowReviewRequestResponse,
WorkflowReviewInboxItem,
WorkflowReviewRequestDetail,
} from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { within } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { useToast } from '@n8n/composables/useToast';
import { createMemoryHistory, createRouter } from 'vue-router';
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '../constants';
import { useReviewActivityStore } from '../reviewActivity.store';
import { useReviewInboxStore } from '../reviewInbox.store';
import WorkflowReviewRequestsView from './WorkflowReviewRequestsView.vue';
vi.mock('@n8n/composables/useToast', () => ({
useToast: vi.fn(),
}));
vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({
set: vi.fn(),
}),
}));
// The changes sections pull in the whole workflow-diff canvas machinery, which
// is far too heavy for these routing/wiring tests.
vi.mock('@/features/workflow-reviews/components/WorkflowReviewChangesSection.vue', () => ({
default: {
name: 'WorkflowReviewChangesSection',
props: ['workflow', 'state', 'decision'],
template: '<div data-test-id="workflow-review-changes-section" />',
},
}));
const showError = vi.fn();
const showMessage = vi.fn();
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/reviews/:reviewRequestId?',
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
component: { template: '<div />' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: { template: '<div />' },
},
],
});
const renderComponent = createComponentRenderer(WorkflowReviewRequestsView, {
global: {
plugins: [router],
stubs: {
PageViewLayout: {
template: '<div data-test-id="workflow-review-requests-view"><slot /></div>',
},
WorkflowReviewRequestsSidebar: {
props: ['sections', 'loading', 'initialLoadFailed'],
template: `
<div
data-test-id="workflow-review-requests-sidebar"
:data-sections="sections.map((section) => section.key).join(',')"
>
<button data-test-id="select-review" @click="$emit('select', 'req-1')" />
<button data-test-id="select-other-review" @click="$emit('select', 'req-2')" />
<button data-test-id="clear-review" @click="$emit('clear')" />
<button data-test-id="select-closed-tab" @click="$emit('update:active-tab', 'closed')" />
<button data-test-id="select-open-tab" @click="$emit('update:active-tab', 'open')" />
<button data-test-id="load-more-authored" @click="$emit('load-more', 'authored')" />
<button data-test-id="retry-waiting" @click="$emit('retry', 'waiting')" />
<button data-test-id="retry-active-tab" @click="$emit('retry-active-tab')" />
</div>`,
},
// The real popover cannot open in jsdom (Reka UI), so let buttons stand in for the
// decisions it emits and expose the props it is handed as attributes.
WorkflowReviewDecisionPopover: {
props: ['deciding', 'viewerCanDecide', 'viewerCanComment', 'ineligibilityHint'],
template: `
<div
data-test-id="workflow-review-decision-popover"
:data-deciding="deciding"
:data-ineligibility-hint="ineligibilityHint"
>
<button data-test-id="approve-review" @click="$emit('decide', { decision: 'approved' })" />
<button
data-test-id="request-changes"
@click="$emit('decide', { decision: 'changes_requested', note: 'needs work' })"
/>
<button data-test-id="comment-posted" @click="$emit('comment-posted')" />
</div>`,
},
},
},
});
describe('WorkflowReviewRequestsView', () => {
let store: ReturnType<typeof mockedStore<typeof useReviewInboxStore>>;
let activityStore: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
beforeEach(async () => {
createTestingPinia();
showError.mockReset();
showMessage.mockReset();
vi.mocked(useToast).mockReturnValue({ showError, showMessage } as unknown as ReturnType<
typeof useToast
>);
await router.push('/reviews');
await router.isReady();
store = mockedStore(useReviewInboxStore);
store.detail = null;
store.detailLoading = false;
store.detailNotFound = false;
store.activeTab = 'open';
store.isEmpty = false;
store.isLoadingActiveTab = false;
store.activeTabInitialLoadFailed = false;
store.hasItemsInActiveTab = false;
store.openCount = null;
store.closedCount = null;
store.fetchSummary.mockResolvedValue(undefined);
store.fetchActiveTab.mockResolvedValue(undefined);
store.fetchDetail.mockResolvedValue(undefined);
store.setActiveTab.mockResolvedValue(undefined);
store.loadMore.mockResolvedValue(undefined);
store.retry.mockResolvedValue(undefined);
store.findItemById.mockReturnValue(null);
store.reset.mockClear();
activityStore = mockedStore(useReviewActivityStore);
activityStore.fetchFeed.mockResolvedValue(undefined);
});
it('loads the counts and active tab on mount', async () => {
renderComponent();
await waitAllPromises();
expect(store.fetchSummary).toHaveBeenCalledTimes(1);
expect(store.fetchActiveTab).toHaveBeenCalledTimes(1);
});
it('shows loading while the active tab has not settled', async () => {
store.isLoadingActiveTab = true;
const { container, queryByTestId } = renderComponent();
await waitAllPromises();
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
expect(queryByTestId('workflow-reviews-empty-state')).not.toBeInTheDocument();
});
it('does not fetch or select a review on the bare inbox path', async () => {
store.hasItemsInActiveTab = true;
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(store.fetchDetail).not.toHaveBeenCalled();
expect(getByTestId('workflow-reviews-no-selection')).toBeInTheDocument();
});
it('heads the no-selection state with the open count', async () => {
store.hasItemsInActiveTab = true;
store.openCount = 4;
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(
within(getByTestId('workflow-reviews-no-selection')).getByText('4 open reviews'),
).toBeInTheDocument();
});
it('names the tab in the no-selection state while the count is unknown', async () => {
store.hasItemsInActiveTab = true;
store.openCount = null;
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(
within(getByTestId('workflow-reviews-no-selection')).getByText('Open reviews'),
).toBeInTheDocument();
});
it('names the closed tab in the no-selection state while the count is unknown', async () => {
await router.replace('/reviews?state=closed');
store.hasItemsInActiveTab = true;
store.closedCount = null;
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(
within(getByTestId('workflow-reviews-no-selection')).getByText('Closed reviews'),
).toBeInTheDocument();
});
it('heads the no-selection state in the singular for one review', async () => {
store.hasItemsInActiveTab = true;
store.openCount = 1;
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(
within(getByTestId('workflow-reviews-no-selection')).getByText('1 open review'),
).toBeInTheDocument();
});
it('heads the no-selection state with the closed count on the closed tab', async () => {
await router.replace('/reviews?state=closed');
store.hasItemsInActiveTab = true;
store.openCount = 4;
store.closedCount = 2;
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(
within(getByTestId('workflow-reviews-no-selection')).getByText('2 closed reviews'),
).toBeInTheDocument();
});
it('shows a retryable error when an initial failure leaves no usable rows', async () => {
store.activeTabInitialLoadFailed = true;
store.isEmpty = false;
store.hasItemsInActiveTab = false;
const { getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
const errorState = getByTestId('workflow-reviews-load-error');
expect(errorState).toHaveTextContent('Could not load workflow reviews');
expect(queryByTestId('workflow-reviews-no-selection')).not.toBeInTheDocument();
expect(queryByTestId('workflow-reviews-empty-state')).not.toBeInTheDocument();
store.fetchActiveTab.mockClear();
within(errorState).getByRole('button', { name: 'Retry' }).click();
await waitAllPromises();
expect(store.fetchActiveTab).toHaveBeenCalledTimes(1);
});
it('shows the no-selection state when a healthy section has rows', async () => {
store.activeTabInitialLoadFailed = true;
store.hasItemsInActiveTab = true;
store.openCount = 12;
const { getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
expect(queryByTestId('workflow-reviews-load-error')).not.toBeInTheDocument();
expect(
within(getByTestId('workflow-reviews-no-selection')).getByText('12 open reviews'),
).toBeInTheDocument();
});
it('fetches the route review detail on mount', async () => {
await router.replace('/reviews/req-1');
renderComponent();
await waitAllPromises();
expect(store.fetchDetail).toHaveBeenCalledWith('req-1');
});
it('opens a review with its activity already loading', async () => {
await router.replace('/reviews/req-1');
renderComponent();
await waitAllPromises();
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
});
it('swaps in the activity of the next review the viewer picks', async () => {
await router.replace('/reviews/req-1');
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('select-other-review').click();
await waitAllPromises();
expect(activityStore.fetchFeed).toHaveBeenLastCalledWith('req-2');
});
it('selects a review with replace and preserves the query', async () => {
await router.replace('/reviews?state=closed');
const replaceSpy = vi.spyOn(router, 'replace');
const pushSpy = vi.spyOn(router, 'push');
const { getByTestId } = renderComponent();
getByTestId('select-review').click();
await waitAllPromises();
expect(replaceSpy).toHaveBeenCalledWith({
params: { reviewRequestId: 'req-1' },
query: { state: 'closed' },
});
expect(pushSpy).not.toHaveBeenCalled();
});
it('drops the tab when selecting a different review, so it lands on Activity', async () => {
await router.replace('/reviews/req-2?state=closed&tab=changes');
const replaceSpy = vi.spyOn(router, 'replace');
const { getByTestId } = renderComponent();
getByTestId('select-review').click();
await waitAllPromises();
expect(replaceSpy).toHaveBeenCalledWith({
params: { reviewRequestId: 'req-1' },
query: { state: 'closed' },
});
});
it('keeps the tab when re-selecting the review already open', async () => {
await router.replace('/reviews/req-1?tab=changes');
const replaceSpy = vi.spyOn(router, 'replace');
const { getByTestId } = renderComponent();
getByTestId('select-review').click();
await waitAllPromises();
expect(replaceSpy).toHaveBeenCalledWith({
params: { reviewRequestId: 'req-1' },
query: { tab: 'changes' },
});
});
it('clears the selection back to the bare inbox path', async () => {
await router.replace('/reviews/req-1?state=closed');
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('clear-review').click();
await waitAllPromises();
expect(router.currentRoute.value.fullPath).toBe('/reviews?state=closed');
expect(router.currentRoute.value.params.reviewRequestId).toBe('');
expect(store.clearDetail).toHaveBeenCalled();
});
it('shows the detail skeleton while deep-linked detail is loading', async () => {
await router.replace('/reviews/req-1');
store.detailLoading = true;
store.detail = null;
const { container, queryByTestId } = renderComponent();
await waitAllPromises();
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
expect(queryByTestId('workflow-reviews-no-selection')).not.toBeInTheDocument();
});
it('renders an inline not-found state without redirecting', async () => {
await router.replace('/reviews/missing');
store.detailNotFound = true;
const replaceSpy = vi.spyOn(router, 'replace');
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-detail-not-found')).toBeInTheDocument();
expect(replaceSpy).not.toHaveBeenCalled();
expect(router.currentRoute.value.fullPath).toBe('/reviews/missing');
});
it('uses the list item until loaded detail is available', async () => {
await router.replace('/reviews/req-1');
// Resolved by id across sections, so it works from either one.
store.findItemById.mockReturnValue(createInboxItem());
const { getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-request-title')).toHaveTextContent('List review');
expect(
within(getByTestId('workflow-review-request-title-row')).getByTestId(
'workflow-review-request-status-dot',
),
).toBeInTheDocument();
// The list item carries no eligibility data, so no decision actions yet
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
store.detail = createDetail({ title: 'Detail review' });
await waitAllPromises();
expect(getByTestId('workflow-review-request-title')).toHaveTextContent('Detail review');
expect(getByTestId('workflow-review-decision-popover')).toBeInTheDocument();
});
it('hydrates the tab from the query before loading', async () => {
await router.replace('/reviews?state=closed');
renderComponent();
await waitAllPromises();
expect(store.activeTab).toBe('closed');
expect(store.fetchActiveTab).toHaveBeenCalledTimes(1);
});
it('uses the open tab for an invalid state query', async () => {
await router.replace('/reviews?state=bogus');
renderComponent();
await waitAllPromises();
expect(store.activeTab).toBe('open');
});
it('writes tab changes to the query and preserves the selected review', async () => {
await router.replace('/reviews/req-1');
const { getByTestId } = renderComponent();
getByTestId('select-closed-tab').click();
await waitAllPromises();
expect(router.currentRoute.value.query).toEqual({ state: 'closed' });
expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1');
getByTestId('select-open-tab').click();
await waitAllPromises();
expect(router.currentRoute.value.query).toEqual({});
expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1');
});
it('updates the active tab when navigation changes the state query', async () => {
renderComponent();
await waitAllPromises();
store.setActiveTab.mockClear();
await router.replace('/reviews?state=closed');
await waitAllPromises();
expect(store.setActiveTab).toHaveBeenCalledWith('closed');
});
describe('sidebar sections', () => {
it('passes both authorship sections on the open tab', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-requests-sidebar').dataset.sections).toBe(
'waiting,authored',
);
});
it('passes a single flat section on the closed tab', async () => {
await router.replace('/reviews?state=closed');
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-requests-sidebar').dataset.sections).toBe('closed');
});
it('forwards load-more and retry to the section that asked', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('load-more-authored').click();
getByTestId('retry-waiting').click();
await waitAllPromises();
expect(store.loadMore).toHaveBeenCalledWith('authored');
expect(store.retry).toHaveBeenCalledWith('waiting');
});
it('retries both sections after an initial tab failure', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
store.fetchActiveTab.mockClear();
getByTestId('retry-active-tab').click();
await waitAllPromises();
expect(store.fetchActiveTab).toHaveBeenCalledTimes(1);
});
});
describe('detail tabs', () => {
beforeEach(async () => {
await router.replace('/reviews/req-1');
store.detail = createDetail();
});
it('defaults to the activity tab', async () => {
const { getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-activity-panel')).toBeInTheDocument();
expect(queryByTestId('workflow-review-changes-panel')).not.toBeInTheDocument();
});
it('hydrates the changes tab from the query', async () => {
await router.replace('/reviews/req-1?tab=changes');
const { getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-changes-panel')).toBeInTheDocument();
expect(queryByTestId('workflow-review-activity-panel')).not.toBeInTheDocument();
});
it('falls back to the activity tab for an invalid tab query', async () => {
await router.replace('/reviews/req-1?tab=bogus');
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-activity-panel')).toBeInTheDocument();
});
it('writes the tab to the query preserving selection and state', async () => {
await router.replace('/reviews/req-1?state=closed');
const { getByRole } = renderComponent();
await waitAllPromises();
getByRole('tab', { name: 'Changes' }).click();
await waitAllPromises();
expect(router.currentRoute.value.query).toEqual({ state: 'closed', tab: 'changes' });
expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1');
getByRole('tab', { name: 'Activity' }).click();
await waitAllPromises();
expect(router.currentRoute.value.query).toEqual({ state: 'closed' });
expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1');
});
});
describe('decision actions', () => {
const decisionResponse = (
overrides: Partial<DecideWorkflowReviewRequestResponse> = {},
): DecideWorkflowReviewRequestResponse => ({
id: 'req-1',
state: 'closed',
decision: 'approved',
workflowVersionId: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
...overrides,
});
beforeEach(async () => {
await router.replace('/reviews/req-1');
store.detail = createDetail();
store.decideOnReview.mockResolvedValue(decisionResponse());
});
it('offers no decision actions for a closed review', async () => {
store.detail = createDetail({ state: 'closed', decision: 'approved' });
const { queryByTestId } = renderComponent();
await waitAllPromises();
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
});
it('submits an approval for the selected review', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(store.decideOnReview).toHaveBeenCalledWith('req-1', { decision: 'approved' });
expect(showError).not.toHaveBeenCalled();
});
it('refetches the detail after a decision on the selected review', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
store.fetchDetail.mockClear();
getByTestId('approve-review').click();
await waitAllPromises();
expect(store.fetchDetail).toHaveBeenCalledWith('req-1');
});
it('submits a change request for the selected review', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('request-changes').click();
await waitAllPromises();
expect(store.decideOnReview).toHaveBeenCalledWith('req-1', {
decision: 'changes_requested',
note: 'needs work',
});
});
it('shows a success toast when the approval published the workflow', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({ autoPublish: { status: 'published' } }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showMessage).toHaveBeenCalledWith({
type: 'success',
title: 'Review approved',
message: 'The reviewed workflow version has been published.',
});
expect(showError).not.toHaveBeenCalled();
});
it('shows a warning toast with the reason when the auto-publish failed', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showMessage).toHaveBeenCalledWith({
type: 'warning',
duration: 0,
title: 'Review approved, but the workflow is not published',
message: 'Version not found. Publish the workflow manually to retry.',
});
expect(showError).not.toHaveBeenCalled();
});
it('does not double up punctuation on an already-terminated message', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({
autoPublish: { status: 'failed', message: 'Cannot activate an archived workflow.' },
}),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Cannot activate an archived workflow. Publish the workflow manually to retry.',
}),
);
});
it('shows no publish toast when requesting changes', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({ state: 'open', decision: 'changes_requested' }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
store.fetchDetail.mockClear();
getByTestId('request-changes').click();
await waitAllPromises();
expect(showMessage).not.toHaveBeenCalled();
expect(store.fetchDetail).not.toHaveBeenCalled();
});
it('follows a closed review to the closed tab, keeping it selected', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({ autoPublish: { status: 'published' } }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1?state=closed');
expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1');
});
// The review closes on approval whether or not the publish succeeded, and a
// failure is exactly when the card is needed to retry from.
it('follows the review to the closed tab even when the auto-publish failed', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1?state=closed');
});
it('stays on the open tab when the review stays open', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({ state: 'open', decision: 'changes_requested' }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('request-changes').click();
await waitAllPromises();
expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1');
});
it('does not renavigate when already on the closed tab', async () => {
await router.replace('/reviews/req-1?state=closed');
store.activeTab = 'closed';
const { getByTestId } = renderComponent();
await waitAllPromises();
const replaceSpy = vi.spyOn(router, 'replace');
getByTestId('approve-review').click();
await waitAllPromises();
expect(replaceSpy).not.toHaveBeenCalled();
expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1?state=closed');
});
it('shows an error toast when the decision fails', async () => {
const error = new Error('forbidden');
store.decideOnReview.mockRejectedValueOnce(error);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision');
});
// The detail pane wins over the list item, so a failed decision must refresh it
// too — otherwise the pane stays open and actionable and every retry re-fails.
it('refreshes the detail, both sections and the feed when the decision fails', async () => {
store.decideOnReview.mockRejectedValueOnce(new Error('conflict'));
const { getByTestId } = renderComponent();
await waitAllPromises();
store.fetchDetail.mockClear();
activityStore.fetchFeed.mockClear();
store.fetchActiveTab.mockClear();
getByTestId('approve-review').click();
await waitAllPromises();
expect(store.fetchActiveTab).toHaveBeenCalledTimes(1);
expect(store.fetchDetail).toHaveBeenCalledWith('req-1');
// The other reviewer's decision entry is in the feed, not in the detail payload.
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
// The note stays, so the reviewer can retry with it.
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
});
it('shows the submitted decision in the feed and drops the note behind it', async () => {
store.decideOnReview.mockResolvedValueOnce(
decisionResponse({ state: 'open', decision: 'changes_requested' }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
activityStore.fetchFeed.mockClear();
getByTestId('request-changes').click();
await waitAllPromises();
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
// The note it submitted, so a note typed while the decision was in flight survives.
expect(activityStore.clearDecisionNote).toHaveBeenCalledWith('needs work');
});
// Otherwise the late refetch of the decided review wipes the feed of the one the
// viewer is now reading.
it('leaves the feed alone when the decision lands after the viewer moved on', async () => {
let resolveDecision!: () => void;
store.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
resolveDecision = () =>
resolve(decisionResponse({ state: 'open', decision: 'changes_requested' }));
}),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await router.replace('/reviews/req-2');
await waitAllPromises();
activityStore.fetchFeed.mockClear();
resolveDecision();
await waitAllPromises();
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
});
// Following the approved review to the closed tab would also switch the feed back,
// throwing away the comment and note the viewer started on the review they moved to.
it('keeps the viewer on the review they moved to when the approval lands', async () => {
let resolveDecision!: () => void;
store.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
resolveDecision = () => resolve(decisionResponse());
}),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await router.replace('/reviews/req-2');
await waitAllPromises();
resolveDecision();
await waitAllPromises();
expect(router.currentRoute.value.fullPath).toBe('/reviews/req-2');
});
it('shows no publish toast once the viewer has left the page', async () => {
let resolveDecision!: () => void;
store.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
resolveDecision = () =>
resolve(
decisionResponse({
autoPublish: { status: 'failed', message: 'Version not found' },
}),
);
}),
);
const { getByTestId, unmount } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
unmount();
resolveDecision();
await waitAllPromises();
expect(showMessage).not.toHaveBeenCalled();
});
it('shows no failure toast once the viewer has left the page', async () => {
let rejectDecision!: (error: Error) => void;
store.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((_resolve, reject) => {
rejectDecision = reject;
}),
);
const { getByTestId, unmount } = renderComponent();
await waitAllPromises();
store.fetchActiveTab.mockClear();
getByTestId('approve-review').click();
unmount();
rejectDecision(new Error('conflict'));
await waitAllPromises();
expect(showError).not.toHaveBeenCalled();
expect(store.fetchActiveTab).not.toHaveBeenCalled();
});
// The tab write is reached from a resolved post too, by which time the viewer may be
// on a page where `tab` means something else entirely.
it('leaves the query of another page alone when a comment lands late', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
await router.replace('/settings/roles?tab=roles');
getByTestId('comment-posted').click();
await waitAllPromises();
expect(router.currentRoute.value.fullPath).toBe('/settings/roles?tab=roles');
});
it('locks the decision actions while a decision is in flight', async () => {
let resolveDecision!: () => void;
store.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
resolveDecision = () => resolve(decisionResponse());
}),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await vi.waitFor(() => {
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
'data-deciding',
'true',
);
});
resolveDecision();
await waitAllPromises();
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
'data-deciding',
'false',
);
});
});
it('clears the stores on entry, before loading counts or lists', async () => {
renderComponent();
expect(store.reset).toHaveBeenCalledTimes(1);
expect(store.reset.mock.invocationCallOrder[0]).toBeLessThan(
store.fetchSummary.mock.invocationCallOrder[0],
);
expect(store.reset.mock.invocationCallOrder[0]).toBeLessThan(
store.fetchActiveTab.mock.invocationCallOrder[0],
);
});
});
function createInboxItem(): WorkflowReviewInboxItem {
return {
id: 'req-1',
projectId: 'proj-1',
title: 'List review',
workflowName: 'My workflow',
workflowVersionId: null,
requester: null,
authors: [],
reviewers: [],
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
}
function createDetail(
overrides: Partial<WorkflowReviewRequestDetail> = {},
): WorkflowReviewRequestDetail {
return {
...createInboxItem(),
description: null,
workflows: [],
viewerCanDecide: true,
viewerDecisionIneligibilityReason: null,
viewerCanComment: true,
...overrides,
};
}

View File

@ -1,515 +0,0 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import type { WorkflowReviewRequestState } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import {
N8nEmptyState,
N8nHeading,
N8nLoading,
N8nResizeWrapper,
type EmptyStateIconCards,
type IconOrEmoji,
} from '@n8n/design-system';
import { useRoute, useRouter } from 'vue-router';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useResizablePanel } from '@n8n/design-system';
import { LOCAL_STORAGE_WORKFLOW_REVIEW_SIDEBAR_WIDTH } from '@/app/constants/localStorage';
import { useToast } from '@n8n/composables/useToast';
import WorkflowReviewDetailTabs from '../components/WorkflowReviewDetailTabs.vue';
import type { WorkflowReviewDetailTab } from '../components/WorkflowReviewDetailTabs.vue';
import WorkflowReviewRequestsSidebar from '../components/WorkflowReviewRequestsSidebar.vue';
import type { ReviewInboxSidebarSection } from '../components/WorkflowReviewRequestsSidebar.vue';
import WorkflowReviewStatusDot from '../components/WorkflowReviewStatusDot.vue';
import { REVIEW_INBOX_QUERY_PARAM, WORKFLOW_REVIEW_REQUESTS_VIEW } from '../constants';
import { useReviewActivityStore } from '../reviewActivity.store';
import { useReviewInboxStore, type ReviewInboxSectionKey } from '../reviewInbox.store';
import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
const store = useReviewInboxStore();
// The tab round trip destroys the feed subtree, so its lifecycle lives here; the
// feed and the composer read the store themselves.
const activityStore = useReviewActivityStore();
const {
activeTab,
detail,
detailLoading,
detailNotFound,
isEmpty,
isLoadingActiveTab,
activeTabInitialLoadFailed,
hasItemsInActiveTab,
openCount,
closedCount,
} = storeToRefs(store);
// `storeToRefs` does not reach into the nested `sections` object; Pinia already
// unwraps the slice refs, so read them through the store.
function toSidebarSection(key: ReviewInboxSectionKey): ReviewInboxSidebarSection {
const slice = store.sections[key];
return {
key,
items: slice.items,
loadingMore: slice.loadingMore,
hasMore: slice.hasMore,
error: slice.error,
};
}
const sidebarSections = computed<ReviewInboxSidebarSection[]>(() =>
activeTab.value === 'closed'
? [toSidebarSection('closed')]
: [toSidebarSection('waiting'), toSidebarSection('authored')],
);
const route = useRoute();
const router = useRouter();
const contentRef = ref<HTMLElement | null>(null);
const sidebarResizer = useResizablePanel({
container: contentRef,
width: {
localStorageKey: LOCAL_STORAGE_WORKFLOW_REVIEW_SIDEBAR_WIDTH,
defaultSize: function getDefaultWidth(size) {
return Math.min(Math.max(size * 0.25, 240), 400);
},
minSize: 240,
maxSize: function getMaxWidth(size) {
return Math.min(size * 0.5, 640);
},
snap: true,
},
});
const sidebarWidth = sidebarResizer.width;
function firstParam(value: string | string[] | undefined): string | null {
const param = Array.isArray(value) ? value[0] : value;
return param || null;
}
const selectedReviewId = computed(() => firstParam(route.params.reviewRequestId));
/**
* Watchers and resolved requests below both reach this view after the viewer may have left it,
* where the query params it writes mean something else entirely.
*/
function isOnInbox() {
return route.name === WORKFLOW_REVIEW_REQUESTS_VIEW;
}
function stateFromQuery(value: unknown): WorkflowReviewRequestState {
return value === 'closed' ? 'closed' : 'open';
}
// Clear on entry, not exit: a discarded layout-swap copy can unmount after the live
// one has started loading, and a teardown clear would invalidate those requests.
store.reset();
activityStore.reset();
// Hydrate the tab before loading so the first list fetch uses the URL state.
store.activeTab = stateFromQuery(route.query[REVIEW_INBOX_QUERY_PARAM.state]);
const selectedListItem = computed(() =>
selectedReviewId.value ? store.findItemById(selectedReviewId.value) : null,
);
const selectedItem = computed(() => detail.value ?? selectedListItem.value);
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const { showError, showMessage } = useToast();
documentTitle.set(i18n.baseText('workflowReviews.page.title'));
const reviewsIcon: EmptyStateIconCards = {
type: 'cards',
center: 'message-square-text',
sides: ['file-diff', 'git-branch', 'circle-check', 'list', 'message-square'],
};
const alertIcon: IconOrEmoji = { type: 'icon', value: 'circle-alert' };
// Counts are optional; name the tab when its summary is unavailable.
const noSelectionHeading = computed(() => {
const count = activeTab.value === 'closed' ? closedCount.value : openCount.value;
if (count === null) {
return activeTab.value === 'closed'
? i18n.baseText('workflowReviews.closedReviews')
: i18n.baseText('workflowReviews.openReviews');
}
return i18n.baseText(`workflowReviews.noSelection.title.${activeTab.value}`, {
adjustToNumber: count,
interpolate: { count: String(count) },
});
});
let isMounted = false;
function handleLoadError(error: unknown) {
if (!isMounted) return;
showError(error, i18n.baseText('workflowReviews.error.load'));
}
watch(
selectedReviewId,
(id) => {
if (!isOnInbox()) return;
if (id) {
void store.fetchDetail(id).catch(handleLoadError);
// Failures surface in the feed's own error row, never as a second toast.
void activityStore.fetchFeed(id);
} else {
store.clearDetail();
activityStore.reset();
}
},
{ immediate: true },
);
watch(
() => route.query[REVIEW_INBOX_QUERY_PARAM.state],
(next) => {
if (!isOnInbox()) return;
void store.setActiveTab(stateFromQuery(next));
},
);
function onSelect(id: string) {
// Switching reviews lands on Activity tab. Deep links still win.
const query = { ...route.query };
if (id !== selectedReviewId.value) delete query[REVIEW_INBOX_QUERY_PARAM.tab];
void router.replace({ params: { reviewRequestId: id }, query });
}
function onClearSelection() {
void router.replace({ params: { reviewRequestId: '' }, query: route.query });
}
function onActiveTabChange(tab: WorkflowReviewRequestState) {
const query = { ...route.query };
if (tab === 'closed') query[REVIEW_INBOX_QUERY_PARAM.state] = tab;
else delete query[REVIEW_INBOX_QUERY_PARAM.state];
void router.replace({ query });
}
const detailTab = computed<WorkflowReviewDetailTab>(() =>
route.query[REVIEW_INBOX_QUERY_PARAM.tab] === 'changes' ? 'changes' : 'activity',
);
function onDetailTabChange(tab: WorkflowReviewDetailTab) {
if (!isOnInbox()) return;
const query = { ...route.query };
if (tab === 'changes') query[REVIEW_INBOX_QUERY_PARAM.tab] = tab;
else delete query[REVIEW_INBOX_QUERY_PARAM.tab];
void router.replace({ query });
}
function onLoadMore(section: ReviewInboxSectionKey) {
void store.loadMore(section);
}
function onRetrySection(section: ReviewInboxSectionKey) {
void store.retry(section);
}
function onRetryActiveTab() {
void store.fetchActiveTab();
}
const deciding = ref(false);
// backend activation messages are inconsistently punctuated
function asSentence(message: string) {
const trimmed = message.trim();
return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
}
/**
* A decision that closes the review drops its card from the open list, which
* would leave the detail on screen with nothing selected in the sidebar. Follow
* it to the closed tab instead, keeping the selection. The `state` query watcher
* refetches the list from here.
*/
function followClosedReview(id: string) {
if (!isOnInbox()) return;
if (activeTab.value === 'closed') return;
void router.replace({
params: { reviewRequestId: id },
query: { ...route.query, [REVIEW_INBOX_QUERY_PARAM.state]: 'closed' },
});
}
async function onDecide(id: string, input: WorkflowReviewDecisionInput) {
deciding.value = true;
try {
const { autoPublish, state } = await store.decideOnReview(id, input);
// The selection does not change, so the `selectedReviewId` watcher never refires and the
// feed entry and detail this decision just wrote need an explicit refetch — the latter is
// what makes the "approved and published" callout show up without a reload. Guarded
// because the await above lets the viewer pick another review meanwhile: refetching the
// old one would wipe its feed and discard the newer review's in-flight page, and following
// it to the closed tab would yank the viewer off the review they are now typing on.
if (selectedReviewId.value === id) {
activityStore.clearDecisionNote(input.note ?? '');
void activityStore.fetchFeed(id);
if (state === 'closed') {
void store.fetchDetail(id).catch(handleLoadError);
followClosedReview(id);
}
}
// The view can be gone by now, and a toast — the sticky publish warning above all —
// would sit on an unrelated page.
if (!isMounted) return;
if (autoPublish?.status === 'published') {
showMessage({
type: 'success',
title: i18n.baseText('workflowReviews.decision.approved.published.title'),
message: i18n.baseText('workflowReviews.decision.approved.published.message'),
});
} else if (autoPublish?.status === 'failed') {
// The approval itself succeeded and is not reverted; the workflow can
// be published through the regular publish flow, which is the retry.
showMessage({
type: 'warning',
duration: 0,
title: i18n.baseText('workflowReviews.decision.approved.publishFailed.title'),
message: i18n.baseText('workflowReviews.decision.approved.publishFailed.message', {
interpolate: { message: asSentence(autoPublish.message) },
}),
});
}
} catch (error) {
// `onUnmounted` has already reset both stores, so there is nothing left to refresh and
// nowhere for the toast to land but an unrelated page.
if (!isMounted) return;
showError(error, i18n.baseText('workflowReviews.decision.error.title'));
// The decision failed because someone else already decided (409), so
// refetch. Otherwise the item keeps showing as open and every retry
// re-fails.
try {
await Promise.all([
store.fetchActiveTab(),
selectedReviewId.value ? store.fetchDetail(selectedReviewId.value) : undefined,
]);
} catch (refetchError) {
handleLoadError(refetchError);
}
// The feed too, or the panel keeps one missing the decision that beat this one. The
// note stays: the reviewer may want to retry with it.
if (selectedReviewId.value === id) void activityStore.fetchFeed(id);
} finally {
deciding.value = false;
}
}
onMounted(() => {
isMounted = true;
void store.fetchSummary();
void store.fetchActiveTab();
});
onUnmounted(() => {
isMounted = false;
});
</script>
<template>
<PageViewLayout :class="$style.layout" full-width data-test-id="workflow-review-requests-view">
<div ref="contentRef" :class="$style.content">
<N8nResizeWrapper
:class="$style.sidebarResizer"
:style="{ width: `${sidebarWidth}px` }"
:resizer="sidebarResizer"
:supported-directions="['right']"
data-test-id="workflow-reviews-sidebar-resizer"
>
<WorkflowReviewRequestsSidebar
:class="$style.sidebar"
:sections="sidebarSections"
:loading="isLoadingActiveTab"
:initial-load-failed="activeTabInitialLoadFailed"
:active-tab="activeTab"
:open-count="openCount"
:closed-count="closedCount"
:selected-id="selectedReviewId"
@select="onSelect"
@clear="onClearSelection"
@update:active-tab="onActiveTabChange"
@load-more="onLoadMore"
@retry="onRetrySection"
@retry-active-tab="onRetryActiveTab"
/>
</N8nResizeWrapper>
<div :class="$style.main">
<div :class="$style.columnTitle">
<div
v-if="selectedItem"
:class="$style.reviewTitle"
data-test-id="workflow-review-request-title-row"
>
<WorkflowReviewStatusDot
:state="selectedItem.state"
:decision="selectedItem.decision"
/>
<N8nHeading bold tag="h2" size="xlarge" data-test-id="workflow-review-request-title">
{{ selectedItem.title }}
</N8nHeading>
</div>
</div>
<div :class="$style.mainBody">
<div
v-if="selectedReviewId && detailNotFound"
:class="$style.emptyStateWrapper"
data-test-id="workflow-review-detail-not-found"
>
<N8nEmptyState
:class="$style.emptyState"
:icon="alertIcon"
:heading="i18n.baseText('workflowReviews.detail.notFound.title')"
:description="i18n.baseText('workflowReviews.detail.notFound.body')"
/>
</div>
<!-- Must precede the selectedItem branch: on a deep link the review is not
in the list yet, so selectedItem is null while the detail loads. -->
<div v-else-if="selectedReviewId && detailLoading" :class="$style.detailSkeleton">
<N8nLoading :loading="true" :rows="3" />
</div>
<WorkflowReviewDetailTabs
v-else-if="selectedItem"
:review="selectedItem"
:tab="detailTab"
:deciding="deciding"
@update:tab="onDetailTabChange"
@decide="onDecide(selectedItem.id, $event)"
/>
<N8nLoading v-else-if="isLoadingActiveTab" :loading="true" :rows="3" />
<div
v-else-if="activeTabInitialLoadFailed && !hasItemsInActiveTab"
:class="$style.emptyStateWrapper"
data-test-id="workflow-reviews-load-error"
>
<N8nEmptyState
:class="$style.emptyState"
:icon="alertIcon"
:heading="i18n.baseText('workflowReviews.error.load')"
:button-text="i18n.baseText('generic.retry')"
@click:button="onRetryActiveTab"
/>
</div>
<div
v-else-if="isEmpty"
:class="$style.emptyStateWrapper"
data-test-id="workflow-reviews-empty-state"
>
<N8nEmptyState
:class="$style.emptyState"
:icon="reviewsIcon"
:heading="i18n.baseText(`workflowReviews.emptyState.title.${activeTab}`)"
:description="i18n.baseText(`workflowReviews.emptyState.body.${activeTab}`)"
/>
</div>
<div
v-else-if="hasItemsInActiveTab"
:class="$style.emptyStateWrapper"
data-test-id="workflow-reviews-no-selection"
>
<N8nEmptyState
:class="$style.emptyState"
:icon="reviewsIcon"
:heading="noSelectionHeading"
:description="i18n.baseText('workflowReviews.noSelection.body')"
/>
</div>
</div>
</div>
</div>
</PageViewLayout>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/breakpoints';
.layout {
padding-top: 0;
}
.content {
--review-tab-bar--height: var(--height--sm);
--review-tab-bar--indicator-overhang: 15px;
--review-tab-bar--gap: calc(var(--spacing--sm) + var(--review-tab-bar--indicator-overhang));
--review-callout--max-width: 34rem;
--review-activity--max-width: 45rem;
display: flex;
width: 100%;
min-height: 0;
height: 100%;
overflow: hidden;
}
.sidebarResizer {
flex: 0 0 auto;
}
.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);
}
.sidebar,
.main {
padding-top: var(--spacing--lg);
@include breakpoints.breakpoint('sm-and-down') {
padding-top: var(--spacing--sm);
}
}
.columnTitle {
display: flex;
align-items: center;
min-height: var(--spacing--2xl);
padding-bottom: var(--spacing--sm);
}
.reviewTitle {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
}
.mainBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.detailSkeleton {
max-width: var(--review-activity--max-width);
}
.emptyStateWrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyStateWrapper .emptyState {
border: none;
padding: 0;
}
</style>

View File

@ -1,33 +1,14 @@
import type {
CreateWorkflowReviewRequestDto,
DecideWorkflowReviewRequestDto,
DecideWorkflowReviewRequestResponse,
GetWorkflowReviewEligibleReviewersQueryDto,
GetWorkflowReviewInboxSummaryResponse,
ListWorkflowReviewActivityResponse,
ListWorkflowReviewInboxResponse,
UpdateWorkflowReviewRequestVersionDto,
WorkflowReviewActivityEntry,
WorkflowReviewEligibleReviewersList,
WorkflowReviewInboxCategory,
WorkflowReviewRequestDetail,
WorkflowReviewRequestList,
WorkflowReviewRequestState,
WorkflowReviewRequestSummary,
} from '@n8n/api-types';
import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
export type FetchWorkflowReviewInboxParams = {
state?: WorkflowReviewRequestState;
/** Partitions the open tab by authorship */
category?: WorkflowReviewInboxCategory;
limit?: number;
cursor?: string;
};
/** What a reviewer submits with a decision; `pending` is the initial state, never an input. */
export type WorkflowReviewDecisionInput = Pick<DecideWorkflowReviewRequestDto, 'decision' | 'note'>;
/** Workflow-scoped list used by the review status sync (toggle + canvas banner). */
export async function fetchWorkflowReviewRequests(
context: IRestApiContext,
@ -77,67 +58,3 @@ export async function updateWorkflowReviewRequestVersion(
{ ...payload },
);
}
export async function decideWorkflowReviewRequest(
context: IRestApiContext,
workflowReviewRequestId: string,
payload: DecideWorkflowReviewRequestDto,
): Promise<DecideWorkflowReviewRequestResponse> {
return await makeRestApiRequest<DecideWorkflowReviewRequestResponse>(
context,
'POST',
`/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/decision`,
{ ...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);
}
export async function fetchWorkflowReviewRequestDetail(
context: IRestApiContext,
workflowReviewRequestId: string,
): Promise<WorkflowReviewRequestDetail> {
return await makeRestApiRequest(
context,
'GET',
`/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}`,
);
}
export async function fetchWorkflowReviewActivity(
context: IRestApiContext,
workflowReviewRequestId: string,
params: { limit?: number; cursor?: string },
): Promise<ListWorkflowReviewActivityResponse> {
return await makeRestApiRequest(
context,
'GET',
`/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/activity`,
params,
);
}
export async function createWorkflowReviewComment(
context: IRestApiContext,
workflowReviewRequestId: string,
payload: { body: string },
): Promise<WorkflowReviewActivityEntry> {
return await makeRestApiRequest(
context,
'POST',
`/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/comments`,
{ ...payload },
);
}

View File

@ -1,18 +1 @@
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
type UserNameParts = {
firstName?: string | null;
lastName?: string | null;
email?: string | null;
};
export function formatUserDisplayName(user: UserNameParts): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email || '';
}
/** Names whoever produced an activity entry. The fallback is passed in to keep i18n out of here. */
export function formatActorName(actor: UserNameParts | null, fallback: string): string {
return actor ? formatUserDisplayName(actor) : fallback;
}
export { formatUserDisplayName } from '@n8n/frontend-module-inbox';

View File

@ -0,0 +1,108 @@
import type { WorkflowReviewVersionSnapshot } from '@n8n/api-types';
import { createComponentRenderer } from '@n8n/frontend-test-utils';
import { defineComponent, isReactive, reactive } from 'vue';
import type { IWorkflowDb } from '@/Interface';
import WorkflowDiffSlot from './WorkflowDiffSlot.vue';
type DiffProps = {
sourceWorkflow?: IWorkflowDb;
targetWorkflow?: IWorkflowDb;
sourceLabel?: string;
targetLabel?: string;
showFullscreenButton?: boolean;
};
const { captured } = vi.hoisted(() => ({ captured: [] as DiffProps[] }));
vi.mock('./WorkflowDiffView.vue', () => ({
default: defineComponent({
props: [
'sourceWorkflow',
'targetWorkflow',
'sourceLabel',
'targetLabel',
'showFullscreenButton',
],
setup(props) {
captured.push(props as DiffProps);
},
template:
'<div><slot name="sourceLabel" /><slot name="targetLabel" /><slot name="sourceEmptyText" /></div>',
}),
}));
const renderComponent = createComponentRenderer(WorkflowDiffSlot);
function snapshot(): WorkflowReviewVersionSnapshot {
return {
versionId: 'version',
name: null,
createdAt: '2026-01-01T00:00:00.000Z',
nodes: [
{
id: 'node',
name: 'Node',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
nodeGroups: [],
};
}
beforeEach(() => {
captured.length = 0;
});
it('maps snapshots to detached non-reactive editor workflows', () => {
const target = reactive(snapshot());
renderComponent({
props: {
workflowId: 'workflow',
workflowName: 'Workflow',
sourceSnapshot: snapshot(),
targetSnapshot: target,
},
});
const { sourceWorkflow, targetWorkflow } = captured[0];
expect(targetWorkflow).toMatchObject({
id: 'workflow',
name: 'Workflow',
versionId: 'version',
activeVersionId: null,
});
expect(isReactive(target)).toBe(true);
expect(isReactive(sourceWorkflow)).toBe(false);
expect(isReactive(targetWorkflow)).toBe(false);
expect(isReactive(targetWorkflow?.nodes)).toBe(false);
expect(targetWorkflow?.nodes).not.toBe(target.nodes);
if (targetWorkflow) targetWorkflow.nodes[0].name = 'Moved in the diff';
expect(target.nodes[0].name).toBe('Node');
});
it('preserves labels, first-publish text, and the fullscreen control', () => {
const { getByText } = renderComponent({
props: {
workflowId: 'workflow',
workflowName: 'Workflow',
targetSnapshot: snapshot(),
sourceLabel: 'Published',
targetLabel: 'In review',
showFullscreenButton: true,
},
slots: {
sourceLabel: '<span>Previous version</span>',
targetLabel: '<span>Proposed version</span>',
sourceEmptyText: '<span>First publication</span>',
},
});
expect(captured[0]).toMatchObject({
sourceWorkflow: undefined,
sourceLabel: 'Published',
targetLabel: 'In review',
showFullscreenButton: true,
});
expect(getByText('Previous version')).toBeInTheDocument();
expect(getByText('Proposed version')).toBeInTheDocument();
expect(getByText('First publication')).toBeInTheDocument();
});

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { WorkflowReviewVersionSnapshot } from '@n8n/api-types';
import type { SlotWorkflowDiffProps } from '@n8n/frontend-module-sdk';
import { deepCopy } from 'n8n-workflow';
import { computed, markRaw } from 'vue';
import type { IWorkflowDb } from '@/Interface';
import WorkflowDiffView from './WorkflowDiffView.vue';
const props = defineProps<SlotWorkflowDiffProps>();
function toWorkflow(snapshot: WorkflowReviewVersionSnapshot | undefined): IWorkflowDb | undefined {
if (!snapshot) return undefined;
return markRaw(
deepCopy({
id: props.workflowId,
name: props.workflowName,
active: false,
isArchived: false,
createdAt: snapshot.createdAt,
updatedAt: snapshot.createdAt,
versionId: snapshot.versionId,
activeVersionId: null,
nodes: snapshot.nodes,
connections: snapshot.connections,
nodeGroups: snapshot.nodeGroups,
}),
);
}
const sourceWorkflow = computed(() => toWorkflow(props.sourceSnapshot));
const targetWorkflow = computed(() => toWorkflow(props.targetSnapshot));
</script>
<template>
<WorkflowDiffView
:source-workflow="sourceWorkflow"
:target-workflow="targetWorkflow"
:source-label="sourceLabel"
:target-label="targetLabel"
:show-fullscreen-button="showFullscreenButton"
>
<template v-if="$slots.sourceLabel" #sourceLabel><slot name="sourceLabel" /></template>
<template v-if="$slots.targetLabel" #targetLabel><slot name="targetLabel" /></template>
<template v-if="$slots.sourceEmptyText" #sourceEmptyText
><slot name="sourceEmptyText"
/></template>
</WorkflowDiffView>
</template>

View File

@ -3,7 +3,6 @@ import type {
WorkflowPublishHistory,
WorkflowHistory,
} from '@n8n/rest-api-client/api/workflowHistory';
import { useI18n } from '@n8n/i18n';
import dateformat from 'dateformat';
export const getLastPublishedVersion = (
@ -12,27 +11,10 @@ export const getLastPublishedVersion = (
return workflowPublishHistory.findLast((history) => history.event === 'activated');
};
export const generateVersionLabelFromId = (versionId: string) => {
return `Version ${versionId.substring(0, 8)}`;
};
export const getVersionLabel = ({
workflowHistory,
currentVersionId,
}: {
workflowHistory: Pick<WorkflowHistory, 'versionId' | 'name'>;
currentVersionId?: string;
}) => {
const i18n = useI18n();
if (workflowHistory.name) {
return workflowHistory.name;
}
const isCurrentVersion = workflowHistory.versionId === currentVersionId;
return isCurrentVersion
? i18n.baseText('workflowHistory.item.currentChanges')
: generateVersionLabelFromId(workflowHistory.versionId);
};
export {
generateVersionLabelFromId,
getVersionLabel,
} from '@n8n/composables/useWorkflowVersionLabel';
export const formatTimestamp = (value: string) => {
const currentYear = new Date().getFullYear().toString();

View File

@ -25,6 +25,8 @@
"@n8n/composables*": ["../@n8n/composables/src*"],
"@n8n/constants*": ["../../@n8n/constants/src*"],
"@n8n/frontend-module-sdk": ["../@n8n/frontend-module-sdk/src/index.ts"],
"@n8n/frontend-module-inbox": ["../../modules/inbox/frontend/src/index.ts"],
"@n8n/frontend-module-inbox/*": ["../../modules/inbox/frontend/src/*"],
"@n8n/frontend-module-mcp": ["../../modules/mcp/frontend/src/index.ts"],
"@n8n/frontend-module-mcp/*": ["../../modules/mcp/frontend/src/*"],
"@n8n/frontend-module-type-availability-policies": [

View File

@ -0,0 +1,60 @@
# @n8n/frontend-module-inbox
The shared Inbox lists workflow reviews. Review details, comments, and decisions live in `src/reviews`. The editor supplies only the workflow diff renderer through the SDK component registry. Backend services keep their existing access rules and actions.
Open has two groups: Waiting for review and Authored by you. Each group has its
own cursor, Load more button, and retry.
Closed is a flat list with infinite scroll. Group collapse state persists per user.
Review decisions update the loaded rows and counts in place. Approving a selected
review opens the Closed tab, as before.
Frontend feature module. Consumed from source by the editor-ui shell through
`src/app/modules.manifest.ts`; there is no build step and no `dist`.
Read the full guide at `packages/@n8n/module-cli/frontend-module-guide.md`. It gives the
descriptor contract, the registration points and the known problems. This README gives a
summary only.
```bash
pnpm turbo typecheck --filter=@n8n/frontend-module-inbox
pnpm turbo lint --filter=@n8n/frontend-module-inbox
pnpm turbo lint:styles --filter=@n8n/frontend-module-inbox
pnpm turbo test --filter=@n8n/frontend-module-inbox
```
Go through turbo, not `pnpm --filter <pkg> typecheck`: this package is consumed
from source, and on a cold tree its platform dependencies have not been built
yet. Turbo builds them first; the bare pnpm form does not.
## Import rules
- Depend on foundation and platform packages only (`@n8n/design-system`,
`@n8n/stores`, `@n8n/composables`, `@n8n/i18n`, `@n8n/rest-api-client`,
`@n8n/api-types`, `@n8n/permissions`, `@n8n/frontend-constants`, `n8n-workflow`,
`@n8n/frontend-module-sdk`). Never import another
`@n8n/frontend-module-*`, and never import `@/…` from the shell.
- `@n8n/stores` and `@n8n/composables` are **subpath-only** — import
`@n8n/stores/settings.store`, not `@n8n/stores`.
- The no-cross-module rule is currently a convention: the shared tsconfig base
omits sibling modules from `paths`, which blocks an accidental import but not
a deliberate one (declaring the dependency makes it typecheck clean). The
ESLint rule that actually enforces it is CAT-3692.
## Adding UI
`vite.config.ts` already carries the three plugins a design-system consumer
needs, so a `.vue` file compiles in tests without further setup:
- `@vitejs/plugin-vue` — single-file components.
- `unplugin-icons` — the `~icons/lucide/*` virtual modules behind `N8nIcon`.
- `vite-svg-loader` — design-system's `custom/*.svg` icon components. Without
it an `.svg` import is a data-URI string, which Vue renders as a tag name.
`src/__tests__/design-system-icons.test.ts` renders one icon of each kind, so a
plugin dropped from `vite.config.ts` fails this package's own suite. Keep that
test while the module renders any design-system component.
SCSS goes in a scoped `<style lang="scss">` block, with tokens from
design-system — `stylelint.config.mjs` holds this package to the same rules the
shell uses. Route components must load lazily.

View File

@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}

View File

@ -0,0 +1,4 @@
import { defineConfig } from 'eslint/config';
import { frontendConfig } from '@n8n/eslint-config/frontend';
export default defineConfig(frontendConfig);

View File

@ -0,0 +1,65 @@
{
"name": "@n8n/frontend-module-inbox",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"clean": "rimraf .turbo",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:changed": "janitor test-scoped",
"test:dev": "vitest",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --allow-empty-input",
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --allow-empty-input",
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/composables": "workspace:*",
"@n8n/design-system": "workspace:*",
"@n8n/frontend-module-sdk": "workspace:*",
"@n8n/i18n": "workspace:*",
"@n8n/rest-api-client": "workspace:*",
"@n8n/stores": "workspace:*",
"n8n-workflow": "workspace:*",
"pinia": "catalog:frontend",
"vue": "catalog:frontend",
"vue-router": "catalog:frontend",
"@n8n/frontend-constants": "workspace:*",
"@vueuse/core": "catalog:frontend",
"lodash": "catalog:",
"vue-i18n": "catalog:frontend"
},
"devDependencies": {
"@iconify/json": "catalog:",
"@n8n/eslint-config": "workspace:*",
"@n8n/frontend-test-utils": "workspace:*",
"@n8n/frontend-vite-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"@vitejs/plugin-vue": "catalog:frontend",
"eslint": "catalog:",
"stylelint": "catalog:",
"typescript": "catalog:",
"unplugin-icons": "catalog:frontend",
"vite": "catalog:",
"vite-svg-loader": "catalog:frontend",
"vitest": "catalog:",
"vue-tsc": "catalog:frontend",
"@pinia/testing": "^0.1.6",
"@testing-library/user-event": "catalog:frontend",
"@types/lodash": "catalog:",
"@n8n/utils": "workspace:*"
},
"license": "LicenseRef-n8n-sustainable-use"
}

View File

@ -0,0 +1,24 @@
import { N8nIcon } from '@n8n/design-system';
import { render } from '@testing-library/vue';
/**
* A build-config test, not a feature test. `@n8n/design-system` is consumed from source, so its
* two icon mechanisms are this package's problem, and each package carries its own
* `vite.config.ts`. Both cases fail loudly when a plugin goes missing from that file:
*
* - `box` comes from `~icons/lucide/box`, which only `unplugin-icons` resolves.
* - `webhook` comes from `custom/webhook.svg`, which only `svgLoader` turns into a component.
* Without it the import is a data-URI string, which Vue renders as a tag name.
*
* Keep this test while the module renders any design-system component.
*/
describe('design-system icons', () => {
it.each([
['a lucide icon', 'box'],
['a custom svg icon', 'webhook'],
])('renders %s', (_case, icon) => {
const { container } = render(N8nIcon, { props: { icon } });
expect(container.querySelector('svg')).not.toBeNull();
});
});

View File

@ -0,0 +1,4 @@
// The shared frontend test harness: the jsdom environment, english messages, and a fresh pinia
// per test. It lives in `@n8n/frontend-test-utils` rather than in `@n8n/vitest-config`, because
// `@n8n/i18n` devDepends on the latter and importing i18n there closes a cycle in the turbo graph.
import '@n8n/frontend-test-utils/setup';

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { N8nEmptyState, N8nLoading, type EmptyStateIconCards } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { useInboxStore } from '../inbox.store';
const store = useInboxStore();
const {
activeTab,
hasItems,
loading,
initialLoadFailed,
partial,
isEmpty,
openCount,
closedCount,
} = storeToRefs(store);
const i18n = useI18n();
const alertIcon = { type: 'icon', value: 'circle-alert' } as const;
const inboxIcon: EmptyStateIconCards = {
type: 'cards',
center: 'message-square-text',
sides: ['file-diff', 'git-branch', 'circle-check', 'list', 'message-square'],
};
const noSelectionHeading = computed(() => {
const count = activeTab.value === 'closed' ? closedCount.value : openCount.value;
if (count === null) return i18n.baseText(`inbox.items.${activeTab.value}`);
return i18n.baseText(`inbox.noSelection.title.${activeTab.value}`, {
adjustToNumber: count,
interpolate: { count: String(count) },
});
});
</script>
<template>
<N8nLoading v-if="loading" :loading="true" :rows="3" />
<div v-else :class="$style.emptyStateWrapper">
<N8nEmptyState
v-if="initialLoadFailed && !hasItems"
:class="$style.emptyState"
:icon="alertIcon"
:heading="i18n.baseText('workflowReviews.error.load')"
:button-text="i18n.baseText('generic.retry')"
@click:button="store.fetchActiveTab()"
/>
<N8nEmptyState
v-else-if="partial && !hasItems"
:class="$style.emptyState"
:icon="alertIcon"
:heading="i18n.baseText('inbox.partial')"
:button-text="i18n.baseText('generic.retry')"
@click:button="store.refreshListAndSummary()"
/>
<N8nEmptyState
v-else-if="isEmpty"
:class="$style.emptyState"
:icon="inboxIcon"
:heading="i18n.baseText(activeTab === 'open' ? 'inbox.empty.open' : 'inbox.empty.closed')"
:description="i18n.baseText(`inbox.empty.body.${activeTab}`)"
data-test-id="inbox-empty"
/>
<N8nEmptyState
v-else-if="hasItems"
:class="$style.emptyState"
:icon="inboxIcon"
:heading="noSelectionHeading"
:description="i18n.baseText('inbox.noSelection.body')"
/>
</div>
</template>
<style lang="scss" module>
.emptyStateWrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyStateWrapper .emptyState {
border: none;
padding: 0;
}
</style>

View File

@ -0,0 +1,229 @@
import type { InboxWorkflowReviewItem } from '@n8n/api-types';
import { createComponentRenderer } from '@n8n/frontend-test-utils';
import { useUsersStore } from '@n8n/stores/users.store';
import { fireEvent, within } from '@testing-library/vue';
import { useIntersectionObserver } from '@vueuse/core';
import InboxList from './InboxList.vue';
import type { InboxListSection } from './InboxListSection.vue';
vi.mock('@vueuse/core', async (importOriginal) => ({
...(await importOriginal<typeof import('@vueuse/core')>()),
useIntersectionObserver: vi.fn(),
}));
function section(
key: InboxListSection['key'],
props: Partial<InboxListSection> = {},
): InboxListSection {
return {
key,
items: [],
loading: false,
loadingMore: false,
hasMore: false,
error: null,
failedRequest: null,
partial: false,
...props,
};
}
function review(): InboxWorkflowReviewItem {
return {
type: 'workflow_review',
id: 'review',
state: 'open',
decision: 'pending',
projectId: 'project',
title: 'Review my workflow',
workflowName: 'My workflow',
workflowVersionId: null,
requester: null,
authors: [],
reviewers: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
}
const renderComponent = createComponentRenderer(InboxList, {
props: {
sections: [section('waiting'), section('authored')],
activeTab: 'open',
selectedKey: null,
openCount: null,
closedCount: null,
},
});
beforeEach(() => localStorage.clear());
function intersect(isIntersecting = true) {
const onIntersect = vi.mocked(useIntersectionObserver).mock.calls.at(-1)![1];
onIntersect([{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver);
}
it('shows one initial skeleton without empty group headers', () => {
const { getAllByTestId, queryByTestId } = renderComponent({
props: {
sections: [section('waiting', { loading: true }), section('authored', { loading: true })],
},
});
expect(getAllByTestId('inbox-list-skeleton')).toHaveLength(1);
expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
});
it('offers one retry when the first load fails without rows', () => {
const { getAllByRole, queryByTestId, emitted } = renderComponent({
props: {
sections: [
section('waiting', { error: new Error('Request failed'), failedRequest: 'list' }),
section('authored', { error: new Error('Request failed'), failedRequest: 'list' }),
],
},
});
const buttons = getAllByRole('button', { name: 'Retry' });
expect(buttons).toHaveLength(1);
buttons[0].click();
expect(emitted('retryActiveTab')).toEqual([[]]);
expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
});
it('retries the failed page after all loaded rows leave the list', () => {
const { getByRole, emitted } = renderComponent({
props: {
sections: [
section('waiting', {
hasMore: true,
error: new Error('Page failed'),
failedRequest: 'loadMore',
}),
section('authored'),
],
},
});
getByRole('button', { name: 'Retry' }).click();
expect(emitted('retry')).toEqual([['waiting']]);
expect(emitted('retryActiveTab')).toBeUndefined();
expect(getByRole('button', { name: 'Waiting for your review' })).toBeInTheDocument();
});
it('keeps healthy rows visible beside a failed group', () => {
const { queryByRole, getByRole, emitted } = renderComponent({
props: {
sections: [
section('waiting', { items: [review()] }),
section('authored', { error: new Error('Request failed') }),
],
},
});
expect(getByRole('option')).toHaveTextContent('Review my workflow');
getByRole('button', { name: 'Retry' }).click();
expect(emitted('retry')).toEqual([['authored']]);
expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
});
it('hides settled empty group headers', () => {
const { queryByTestId } = renderComponent();
expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
});
it('keeps a partial empty group visible with its own retry', () => {
const { getByRole, emitted } = renderComponent({
props: { sections: [section('waiting', { partial: true }), section('authored')] },
});
expect(getByRole('button', { name: 'Waiting for your review' })).toBeInTheDocument();
getByRole('button', { name: 'Retry' }).click();
expect(emitted('refreshSection')).toEqual([['waiting']]);
});
it('collapses groups without requesting more data', async () => {
const { getByRole, queryByRole, emitted } = renderComponent({
props: { sections: [section('waiting', { items: [review()] })] },
});
const header = getByRole('button', { name: 'Waiting for your review' });
await fireEvent.click(header);
expect(header).toHaveAttribute('aria-expanded', 'false');
expect(queryByRole('option')).not.toBeInTheDocument();
await fireEvent.click(header);
expect(getByRole('option')).toHaveTextContent('Review my workflow');
expect(emitted('loadMore')).toBeUndefined();
});
it('uses the impersonal waiting label for administrators', () => {
vi.spyOn(useUsersStore(), 'isAdminOrOwner', 'get').mockReturnValue(true);
const { getByRole } = renderComponent({
props: { sections: [section('waiting', { items: [review()] })] },
});
expect(getByRole('button', { name: 'Waiting for review' })).toBeInTheDocument();
});
it('keeps loading open groups explicit and identifies the group', () => {
const { emitted, getByRole } = renderComponent({
props: { sections: [section('authored', { hasMore: true })] },
});
intersect();
expect(emitted('loadMore')).toBeUndefined();
getByRole('button', { name: 'Load more' }).click();
expect(emitted('loadMore')).toEqual([['authored']]);
});
it('loads Closed at the bottom without group headers or a Load more button', () => {
const { emitted, queryByRole, queryByTestId } = renderComponent({
props: { activeTab: 'closed', sections: [section('closed', { hasMore: true })] },
});
intersect(false);
expect(emitted('loadMore')).toBeUndefined();
intersect();
expect(emitted('loadMore')).toEqual([['closed']]);
expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument();
});
it.each([{ loading: true }, { loadingMore: true }, { hasMore: false }])(
'does not automatically load Closed with %o',
(props) => {
const { emitted } = renderComponent({
props: { activeTab: 'closed', sections: [section('closed', { hasMore: true, ...props })] },
});
intersect();
expect(emitted('loadMore')).toBeUndefined();
},
);
it('waits for Retry after a failed Closed page and removes the sentinel on Open', async () => {
const closed = section('closed', { hasMore: true, items: [{ ...review(), state: 'closed' }] });
const { rerender, emitted } = renderComponent({
props: { activeTab: 'closed', sections: [closed] },
});
const target = vi.mocked(useIntersectionObserver).mock.calls.at(-1)![0];
expect(target).not.toHaveProperty('value', null);
intersect();
await rerender({ sections: [{ ...closed, loadingMore: true }] });
expect(target).toHaveProperty('value', null);
intersect();
expect(emitted('loadMore')).toHaveLength(1);
await rerender({
sections: [{ ...closed, error: new Error('Page failed') }],
});
expect(target).toHaveProperty('value', null);
intersect();
expect(emitted('loadMore')).toHaveLength(1);
await rerender({ sections: [closed] });
expect(target).not.toHaveProperty('value', null);
intersect();
expect(emitted('loadMore')).toHaveLength(2);
await rerender({ activeTab: 'open', sections: [section('waiting')] });
expect(target).toHaveProperty('value', null);
});
it('separates partial-source recovery from a failed page retry', () => {
const { getByTestId, emitted } = renderComponent({
props: { sections: [section('waiting', { partial: true, error: new Error('Page failed') })] },
});
within(getByTestId('inbox-partial')).getByRole('button', { name: 'Retry' }).click();
expect(emitted('refreshSection')).toEqual([['waiting']]);
expect(emitted('retry')).toBeUndefined();
within(getByTestId('inbox-list-error')).getByRole('button', { name: 'Retry' }).click();
expect(emitted('retry')).toEqual([['waiting']]);
});
it('keeps review selection and keyboard activation across the extracted row', async () => {
const item = review();
const { getByRole, emitted } = renderComponent({
props: {
selectedKey: `${item.type}:${item.id}`,
sections: [section('waiting', { items: [item] })],
},
});
const row = getByRole('option', { selected: true });
expect(row).toHaveTextContent(item.title);
await fireEvent.keyDown(row, { key: 'Enter' });
await fireEvent.keyDown(row, { key: ' ' });
expect(emitted('select')).toEqual([[item], [item]]);
});

View File

@ -0,0 +1,195 @@
<script setup lang="ts">
import type { InboxItem, InboxState } from '@n8n/api-types';
import { N8nButton, N8nHeading, N8nLoading, N8nTabs, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useIntersectionObserver } from '@vueuse/core';
import { computed, useTemplateRef } from 'vue';
import { useReviewInboxSectionCollapse } from '../composables/useReviewInboxSectionCollapse';
import type { InboxSectionKey } from '../inbox.constants';
import InboxListSection from './InboxListSection.vue';
import type { InboxListSection as ListSection } from './InboxListSection.vue';
const props = defineProps<{
sections: ListSection[];
activeTab: InboxState;
selectedKey: string | null;
openCount: number | null;
closedCount: number | null;
}>();
const emit = defineEmits<{
select: [item: InboxItem];
clear: [];
'update:active-tab': [state: InboxState];
loadMore: [section: InboxSectionKey];
retry: [section: InboxSectionKey];
retryActiveTab: [];
refreshSection: [section: InboxSectionKey];
}>();
const i18n = useI18n();
const { isCollapsed, toggleSection } = useReviewInboxSectionCollapse();
const loading = computed(() => props.sections.some((section) => section.loading));
const hasUsableRows = computed(() => props.sections.some((section) => section.items.length > 0));
const showInitialLoadError = computed(
() =>
!hasUsableRows.value &&
props.sections.some((section) => section.failedRequest === 'list') &&
!props.sections.some((section) => section.partial),
);
const visibleSections = computed(() =>
props.sections.filter(
(section) =>
!loading.value &&
!showInitialLoadError.value &&
(section.error || section.partial || section.items.length > 0 || section.hasMore),
),
);
const listRef = useTemplateRef<HTMLElement>('list');
const loadMoreSentinel = useTemplateRef<HTMLElement>('loadMoreSentinel');
const canAutoLoad = computed(() => {
const closed = props.sections.find((section) => section.key === 'closed');
// A failed page waits for Retry. A visible sentinel would otherwise retry in a loop.
return (
props.activeTab === 'closed' &&
closed?.hasMore &&
!loading.value &&
!closed.loadingMore &&
!closed.error
);
});
useIntersectionObserver(
loadMoreSentinel,
([entry]) => {
if (entry?.isIntersecting && canAutoLoad.value) emit('loadMore', 'closed');
},
{ root: listRef, threshold: 0.01 },
);
const tabs = computed(() => [
{
value: 'open',
label: i18n.baseText('inbox.tabs.open'),
tag: props.openCount === null ? undefined : String(props.openCount),
},
{
value: 'closed',
label: i18n.baseText('inbox.tabs.closed'),
tag: props.closedCount === null ? undefined : String(props.closedCount),
},
]);
function onTabChange(value: string | number | boolean) {
if (value === 'open' || value === 'closed') emit('update:active-tab', value);
}
function onListBackgroundClick() {
if (props.selectedKey) emit('clear');
}
</script>
<template>
<aside :class="$style.sidebar" data-test-id="inbox-list">
<div :class="$style.title">
<N8nHeading bold tag="h2" size="xlarge">{{ i18n.baseText('inbox.title') }}</N8nHeading>
</div>
<div :class="$style.header">
<N8nTabs
:model-value="activeTab"
:options="tabs"
variant="modern"
data-test-id="inbox-tabs"
@update:model-value="onTabChange"
/>
</div>
<div ref="list" :class="$style.list" @click.self="onListBackgroundClick">
<N8nLoading v-if="loading" :loading="true" :rows="3" data-test-id="inbox-list-skeleton" />
<div
v-else-if="showInitialLoadError"
:class="$style.sectionError"
role="alert"
data-test-id="inbox-list-error"
>
<N8nText color="danger" size="small">{{ i18n.baseText('inbox.loadError') }}</N8nText>
<N8nButton
variant="outline"
size="mini"
:label="i18n.baseText('generic.retry')"
@click="emit('retryActiveTab')"
/>
</div>
<InboxListSection
v-for="section in visibleSections"
:key="section.key"
:section="section"
:collapsed="section.key !== 'closed' && isCollapsed(section.key)"
:selected-key="selectedKey"
@select="emit('select', $event)"
@toggle="toggleSection"
@load-more="emit('loadMore', section.key)"
@retry="emit('retry', section.key)"
@refresh-section="emit('refreshSection', section.key)"
/>
<div v-if="canAutoLoad" ref="loadMoreSentinel" :class="$style.sentinel" />
<div
v-else-if="activeTab === 'closed' && !loading && !showInitialLoadError"
:class="$style.sentinel"
/>
</div>
</aside>
</template>
<style module lang="scss">
.sidebar {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
border-right: var(--border-width) solid var(--border-color);
}
.title {
display: flex;
align-items: center;
min-height: var(--spacing--2xl);
padding: 0 var(--spacing--md) var(--spacing--sm) 0;
}
.header {
position: relative;
display: flex;
align-items: center;
height: var(--review-tab-bar--height, var(--height--sm));
padding-right: var(--spacing--md);
margin-bottom: var(--review-tab-bar--gap);
&::after {
content: '';
position: absolute;
left: 0;
right: var(--spacing--md);
bottom: calc(-1 * var(--review-tab-bar--indicator-overhang) - var(--border-width));
border-bottom: var(--border-width) solid var(--border-color);
}
}
.list {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing--sm);
min-width: 0;
overflow-y: auto;
padding: 0 var(--spacing--md) var(--spacing--md) 0;
}
.sectionError {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--2xs);
}
.sentinel {
height: var(--border-width);
}
</style>

View File

@ -0,0 +1,134 @@
<script setup lang="ts">
import type { InboxItem } from '@n8n/api-types';
import { N8nBadge, N8nCard, N8nIcon, N8nText, N8nTimeAgo } from '@n8n/design-system';
import { useRootStore } from '@n8n/stores/useRootStore';
import WorkflowReviewStatusDot from '../reviews/components/WorkflowReviewStatusDot.vue';
defineProps<{ item: InboxItem; selected: boolean }>();
const emit = defineEmits<{ select: [] }>();
const rootStore = useRootStore();
</script>
<template>
<N8nCard
:class="[$style.card, { [$style.selected]: selected }]"
role="option"
tabindex="0"
:aria-selected="selected"
:data-source="item.type"
data-test-id="inbox-row"
@click="emit('select')"
@keydown.enter.prevent="emit('select')"
@keydown.space.prevent="emit('select')"
>
<div :class="$style.cardContent">
<div :class="$style.cardHeader">
<N8nText bold tag="h3" :class="$style.cardTitle">{{ item.title }}</N8nText>
<WorkflowReviewStatusDot :state="item.state" :decision="item.decision" />
</div>
<div :class="$style.meta">
<N8nBadge v-if="item.workflowName" variant="outline" :class="$style.workflowBadge">
<span :class="$style.workflowBadgeText" :title="item.workflowName"
><N8nIcon icon="workflow" size="small" /><span>{{ item.workflowName }}</span></span
>
</N8nBadge>
<div :class="$style.metaActions">
<N8nText :class="$style.time" size="xsmall" color="text-light">
<N8nTimeAgo :date="item.createdAt" :locale="rootStore.defaultLocale" />
</N8nText>
</div>
</div>
</div>
</N8nCard>
</template>
<style module lang="scss">
.card {
cursor: pointer;
padding: var(--spacing--xs);
align-items: stretch;
border: var(--border-width) solid var(--border-color);
}
.card:hover:not(.selected) {
background-color: var(--background--hover);
border-color: transparent;
}
.selected {
background-color: var(--background--active);
border-color: transparent;
}
.card:focus-visible {
border-color: var(--focus--border-color);
}
.cardContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--2xs);
min-width: 0;
width: 100%;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
width: 100%;
min-width: 0;
}
.cardTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-size: var(--font-size--sm);
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
width: 100%;
min-width: 0;
}
.workflowBadge {
flex: 0 1 auto;
min-width: 0;
border: var(--border);
border-radius: var(--radius);
padding: var(--spacing--4xs) var(--spacing--2xs);
color: var(--color--text);
> 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) + var(--border-width));
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
}
.metaActions {
display: flex;
align-items: center;
gap: var(--spacing--sm);
margin-left: auto;
flex-shrink: 0;
}
.time {
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,168 @@
<script setup lang="ts">
import type { InboxItem } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nLoading, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useUsersStore } from '@n8n/stores/users.store';
import type { CollapsibleReviewInboxSection } from '../composables/useReviewInboxSectionCollapse';
import type { InboxSectionKey } from '../inbox.constants';
import InboxListItem from './InboxListItem.vue';
export type InboxListSection = {
key: InboxSectionKey;
items: InboxItem[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
error: Error | null;
failedRequest: 'list' | 'loadMore' | null;
partial: boolean;
};
defineProps<{
section: InboxListSection;
collapsed: boolean;
selectedKey: string | null;
}>();
const emit = defineEmits<{
select: [item: InboxItem];
toggle: [key: CollapsibleReviewInboxSection];
loadMore: [];
retry: [];
refreshSection: [];
}>();
const i18n = useI18n();
const usersStore = useUsersStore();
function sectionTitle(key: 'waiting' | 'authored') {
return key === 'waiting' && usersStore.isAdminOrOwner
? i18n.baseText('workflowReviews.sidebar.section.waiting.titleAdmin')
: i18n.baseText(`workflowReviews.sidebar.section.${key}.title`);
}
</script>
<template>
<div :class="$style.section" :data-section="section.key">
<button
v-if="section.key !== 'closed'"
:id="`inbox-section-header-${section.key}`"
type="button"
:class="$style.sectionHeader"
:aria-expanded="!collapsed"
:aria-controls="`inbox-section-${section.key}`"
data-test-id="inbox-section-header"
@click="emit('toggle', section.key)"
>
<N8nIcon
icon="chevron-down"
size="small"
:class="[$style.chevron, { [$style.chevronCollapsed]: collapsed }]"
/>
<N8nText bold size="small" color="text-base">{{ sectionTitle(section.key) }}</N8nText>
</button>
<div
:id="`inbox-section-${section.key}`"
:class="$style.group"
role="listbox"
:aria-labelledby="
section.key !== 'closed' ? `inbox-section-header-${section.key}` : undefined
"
:aria-label="section.key === 'closed' ? i18n.baseText('inbox.tabs.closed') : undefined"
>
<template v-if="!collapsed">
<InboxListItem
v-for="item in section.items"
:key="`${item.type}:${item.id}`"
:item="item"
:selected="selectedKey === `${item.type}:${item.id}`"
@select="emit('select', item)"
/>
</template>
</div>
<template v-if="!collapsed">
<div v-if="section.loadingMore" :class="$style.loadingMore">
<N8nLoading :loading="true" :rows="1" />
</div>
<div v-if="section.partial" :class="$style.notice" role="status" data-test-id="inbox-partial">
<N8nText size="small">{{ i18n.baseText('inbox.partial') }}</N8nText>
<N8nButton
size="mini"
variant="outline"
:label="i18n.baseText('generic.retry')"
@click="emit('refreshSection')"
/>
</div>
<div v-if="section.error" :class="$style.notice" role="alert" data-test-id="inbox-list-error">
<N8nText color="danger" size="small">{{ i18n.baseText('inbox.loadError') }}</N8nText>
<N8nButton
size="mini"
variant="outline"
:label="i18n.baseText('generic.retry')"
@click="emit('retry')"
/>
</div>
<N8nButton
v-if="section.key !== 'closed' && section.hasMore"
:class="$style.loadMore"
size="small"
variant="outline"
:loading="section.loadingMore"
:disabled="section.loading"
:label="i18n.baseText('inbox.loadMore')"
data-test-id="inbox-load-more"
@click="emit('loadMore')"
/>
</template>
</div>
</template>
<style module lang="scss">
.section,
.group {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
min-width: 0;
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
width: 100%;
padding: var(--spacing--3xs) var(--spacing--2xs);
border: none;
border-radius: var(--radius);
background: transparent;
cursor: pointer;
text-align: left;
}
.sectionHeader:hover {
background-color: var(--background--active);
}
.sectionHeader:focus-visible {
outline: var(--border-width) solid var(--focus--border-color);
}
.chevron {
transition: transform var(--duration--snappy) ease;
color: var(--color--text--tint-1);
}
.chevronCollapsed {
transform: rotate(-90deg);
}
.notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--2xs);
}
.loadMore {
align-self: center;
}
.loadingMore {
padding: var(--spacing--sm);
}
</style>

View File

@ -1,7 +1,6 @@
import { createPinia, setActivePinia } from 'pinia';
import { nextTick } from 'vue';
import { LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS } from '@/app/constants/localStorage';
import { useUsersStore } from '@n8n/stores/users.store';
import { useReviewInboxSectionCollapse } from './useReviewInboxSectionCollapse';
@ -17,9 +16,18 @@ describe('useReviewInboxSectionCollapse', () => {
expect(isCollapsed('waiting')).toBe(false);
expect(isCollapsed('authored')).toBe(false);
expect(
localStorage.getItem(LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS('user-1')),
).toBeNull();
expect(localStorage.getItem('user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBeNull();
});
it('restores the existing review section preference', () => {
useUsersStore().currentUserId = 'user-1';
localStorage.setItem(
'user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS',
JSON.stringify({ waiting: true, authored: false }),
);
const { isCollapsed } = useReviewInboxSectionCollapse();
expect(isCollapsed('waiting')).toBe(true);
expect(isCollapsed('authored')).toBe(false);
});
it('persists a collapsed section for the current user', async () => {
@ -31,9 +39,9 @@ describe('useReviewInboxSectionCollapse', () => {
expect(isCollapsed('authored')).toBe(true);
expect(isCollapsed('waiting')).toBe(false);
expect(
localStorage.getItem(LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS('user-1')),
).toBe(JSON.stringify({ waiting: false, authored: true }));
expect(localStorage.getItem('user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBe(
JSON.stringify({ waiting: false, authored: true }),
);
});
it('keeps the choice independent between users', () => {
@ -53,8 +61,6 @@ describe('useReviewInboxSectionCollapse', () => {
await nextTick();
expect(isCollapsed('waiting')).toBe(true);
expect(
localStorage.getItem(LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS('')),
).toBeNull();
expect(localStorage.getItem('_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBeNull();
});
});

View File

@ -1,7 +1,6 @@
import { useLocalStorage } from '@vueuse/core';
import { ref } from 'vue';
import { LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS } from '@/app/constants/localStorage';
import { useUsersStore } from '@n8n/stores/users.store';
/** Only the open tab is sectioned, so only those two sections can collapse. */
@ -22,7 +21,7 @@ export const useReviewInboxSectionCollapse = () => {
const collapsedSections = userId
? useLocalStorage<CollapsedReviewInboxSections>(
LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS(userId),
`${userId}_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS`,
{ ...DEFAULT_COLLAPSED },
{ writeDefaults: false },
)

View File

@ -0,0 +1,20 @@
import type {
GetInboxSummaryResponse,
InboxCategory,
InboxState,
ListInboxResponse,
} from '@n8n/api-types';
import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
export async function fetchInbox(
context: IRestApiContext,
query: { state: InboxState; category?: InboxCategory; limit: number; cursor?: string },
): Promise<ListInboxResponse> {
return await makeRestApiRequest(context, 'GET', '/inbox', query);
}
export async function fetchInboxSummary(
context: IRestApiContext,
): Promise<GetInboxSummaryResponse> {
return await makeRestApiRequest(context, 'GET', '/inbox/summary');
}

View File

@ -0,0 +1,16 @@
import type { InboxSourceType, InboxState, WorkflowReviewRequestDecision } from '@n8n/api-types';
export const INBOX_VIEW = 'Inbox';
export const INBOX_PAGE_LIMIT = 15;
export type InboxSectionKey = 'waiting' | 'authored' | 'closed';
export type InboxSelection = { type: 'workflow_review'; id: string };
export type InboxItemChange = {
type: InboxSourceType;
id: string;
state?: InboxState;
decision?: WorkflowReviewRequestDecision;
updatedAt?: string;
};

View File

@ -0,0 +1,50 @@
import { defineFrontendModule } from '@n8n/frontend-module-sdk';
import type { RouteRecordSingleView } from 'vue-router';
const inboxView = {
component: async () => await import('./views/InboxView.vue'),
async beforeEnter() {
const { useSettingsStore } = await import('@n8n/stores/settings.store');
return useSettingsStore().settings.inbox?.enabled === true || '/';
},
meta: { layout: 'default', middleware: ['authenticated', 'custom'] },
} satisfies Pick<RouteRecordSingleView, 'component' | 'beforeEnter' | 'meta'>;
export const InboxModule = defineFrontendModule({
id: 'inbox',
name: 'Inbox',
description: 'The shared Inbox for workflow reviews.',
icon: 'inbox',
routes: [
// Keep legacy /reviews links working after the move to Inbox.
{
path: '/reviews/:reviewRequestId?',
redirect: (to) => {
const reviewId = to.params.reviewRequestId;
if (typeof reviewId === 'string' && reviewId) {
return {
name: 'WorkflowReviewRequestsView',
params: { reviewId },
query: to.query,
};
}
return { name: 'Inbox', query: to.query };
},
},
{
...inboxView,
path: '/inbox',
name: 'Inbox',
},
{
...inboxView,
path: '/inbox/reviews/:reviewId',
name: 'WorkflowReviewRequestsView',
},
],
});

View File

@ -0,0 +1,111 @@
import type { InboxWorkflowReviewItem } from '@n8n/api-types';
import { createMemoryHistory, createRouter } from 'vue-router';
import { InboxModule } from './inbox.module';
import { inboxItemLocation, isInboxRoute, selectionFromRoute } from './inbox.routes';
const settingsStore = vi.hoisted(() => ({ settings: { inbox: { enabled: true } } }));
vi.mock('@n8n/stores/settings.store', () => ({ useSettingsStore: () => settingsStore }));
beforeEach(() => {
settingsStore.settings.inbox.enabled = true;
});
function createInboxRouter() {
const routes = (InboxModule.routes ?? []).map((route) =>
'component' in route ? { ...route, component: { template: '<div />' } } : route,
);
return createRouter({ history: createMemoryHistory(), routes });
}
const review = {
type: 'workflow_review',
id: 'review-1',
state: 'open',
projectId: 'project',
title: 'Review',
workflowName: 'Workflow',
workflowVersionId: null,
requester: null,
authors: [],
reviewers: [],
decision: 'pending',
createdAt: '',
updatedAt: '',
} satisfies InboxWorkflowReviewItem;
it('redirects to home when Inbox is disabled', async () => {
settingsStore.settings.inbox.enabled = false;
const router = createInboxRouter();
router.addRoute({ path: '/', name: 'home', component: { template: '<div />' } });
await router.push('/inbox');
expect(router.currentRoute.value).toMatchObject({ path: '/', name: 'home' });
});
it('redirects an old review link and preserves filters and the detail tab', async () => {
const router = createInboxRouter();
await router.push('/reviews/review-1?state=closed&tab=changes&filter=a&filter=b');
expect(router.currentRoute.value.path).toBe('/inbox/reviews/review-1');
expect(router.currentRoute.value.query).toEqual({
state: 'closed',
tab: 'changes',
filter: ['a', 'b'],
});
});
it('redirects the old review list without selecting an item', async () => {
const router = createInboxRouter();
await router.push('/reviews?state=closed');
expect(router.currentRoute.value.fullPath).toBe('/inbox?state=closed');
expect(selectionFromRoute(router.currentRoute.value)).toBeNull();
});
it('selects a review when opening its detail route directly', async () => {
const router = createInboxRouter();
await router.push('/inbox/reviews/review-1');
expect(selectionFromRoute(router.currentRoute.value)).toEqual({
type: 'workflow_review',
id: 'review-1',
});
});
it.each([
['Inbox', true],
['WorkflowReviewRequestsView', true],
['Workflow', false],
[undefined, false],
])('recognizes the Inbox route %s: %s', (name, expected) => {
expect(isInboxRoute({ name })).toBe(expected);
});
it('keeps the tab only when selecting the same item', async () => {
const router = createInboxRouter();
await router.push('/inbox/reviews/review-1?state=closed&tab=changes');
expect(router.resolve(inboxItemLocation(review, router.currentRoute.value))).toMatchObject({
path: '/inbox/reviews/review-1',
query: { state: 'closed', tab: 'changes' },
});
expect(
router.resolve(inboxItemLocation({ ...review, id: 'review-2' }, router.currentRoute.value))
.query,
).toEqual({ state: 'closed' });
});
it('restores selection and tabs when navigating back and forward', async () => {
const router = createInboxRouter();
await router.push('/inbox');
await router.push('/inbox/reviews/review-1?tab=changes');
await router.push(inboxItemLocation({ ...review, id: 'review-2' }, router.currentRoute.value));
router.back();
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/inbox/reviews/review-1?tab=changes'),
);
expect(selectionFromRoute(router.currentRoute.value)).toEqual({
type: 'workflow_review',
id: 'review-1',
});
router.forward();
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/inbox/reviews/review-2'));
expect(selectionFromRoute(router.currentRoute.value)?.id).toBe('review-2');
expect(router.currentRoute.value.query.tab).toBeUndefined();
});

View File

@ -0,0 +1,30 @@
import type { InboxItem } from '@n8n/api-types';
import type { LocationQuery, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
import { INBOX_VIEW, type InboxSelection } from './inbox.constants';
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './reviews/constants';
type InboxRoute = Pick<RouteLocationNormalizedLoaded, 'name' | 'params' | 'query'>;
function stringParam(value: LocationQuery[string]): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export function isInboxRoute(route: Pick<InboxRoute, 'name'>): boolean {
return route.name === INBOX_VIEW || route.name === WORKFLOW_REVIEW_REQUESTS_VIEW;
}
export function selectionFromRoute({ name, params }: InboxRoute): InboxSelection | null {
if (name === WORKFLOW_REVIEW_REQUESTS_VIEW) {
const id = stringParam(params.reviewId);
return id ? { type: 'workflow_review', id } : null;
}
return null;
}
export function inboxItemLocation(item: InboxItem, route?: InboxRoute): RouteLocationRaw {
const next: LocationQuery = { ...route?.query };
const selected = route ? selectionFromRoute(route) : null;
if (selected?.type !== item.type || selected.id !== item.id) delete next.tab;
return { name: WORKFLOW_REVIEW_REQUESTS_VIEW, params: { reviewId: item.id }, query: next };
}

View File

@ -0,0 +1,349 @@
import type { InboxItem, ListInboxResponse } from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import * as api from './inbox.api';
import { createInboxListSlice, useInboxStore } from './inbox.store';
vi.mock('./inbox.api');
const waitingOrClosedRequest = vi.fn<typeof api.fetchInbox>();
const authoredRequest = vi.fn<typeof api.fetchInbox>();
function page(
data: InboxItem[] = [],
overrides: Partial<ListInboxResponse> = {},
): ListInboxResponse {
return {
data,
nextCursor: null,
hasMore: false,
partial: false,
failedSources: [],
disabledSources: [],
...overrides,
};
}
function enableInbox() {
useSettingsStore().settings.inbox = {
enabled: true,
availableTypes: ['workflow_review'],
failedTypes: [],
};
}
beforeEach(() => {
vi.resetAllMocks();
enableInbox();
vi.mocked(api.fetchInboxSummary).mockResolvedValue({
counts: { open: 2, closed: 1 },
partial: false,
failedSources: [],
disabledSources: [],
});
waitingOrClosedRequest.mockResolvedValue(page());
authoredRequest.mockResolvedValue(page());
vi.mocked(api.fetchInbox).mockImplementation(async (context, query) =>
query.category === 'authored'
? await authoredRequest(context, query)
: await waitingOrClosedRequest(context, query),
);
});
it('keeps known counts when a later summary request fails', async () => {
const store = useInboxStore();
await store.fetchSummary();
vi.mocked(api.fetchInboxSummary).mockRejectedValueOnce(new Error('Summary unavailable'));
await store.fetchSummary();
expect(store.openCount).toBe(2);
expect(store.closedCount).toBe(1);
});
describe('Inbox list requests', () => {
it('restarts from the first page and exposes a failed reload', async () => {
const pending = createDeferredPromise<ListInboxResponse>();
const request = vi
.fn()
.mockResolvedValueOnce(page([reviewItem('first')]))
.mockReturnValueOnce(pending.promise);
const slice = createInboxListSlice(request, vi.fn());
await slice.fetchList();
const refresh = slice.fetchList();
expect(slice.items).toEqual([]);
pending.reject(new Error('temporarily unavailable'));
await refresh;
expect(slice.items).toEqual([]);
expect(slice.error).toBeInstanceOf(Error);
});
it('ignores an old load-more response after refreshing from the first page', async () => {
const pending = createDeferredPromise<ListInboxResponse>();
const request = vi
.fn()
.mockResolvedValueOnce(page([reviewItem('first')], { hasMore: true, nextCursor: 'page-2' }))
.mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce(page([reviewItem('newest')]));
const slice = createInboxListSlice(request, vi.fn());
await slice.fetchList();
const loadMore = slice.loadMore();
await slice.fetchList();
pending.resolve(page([reviewItem('older')]));
await loadMore;
expect(slice.items.map((item) => item.id)).toEqual(['newest']);
expect(slice.loadingMore).toBe(false);
});
it('continues a partial cursor chain and retries a failed page without losing its rows', async () => {
const partial = { partial: true, failedSources: ['workflow_review' as const] };
const request = vi
.fn()
.mockResolvedValueOnce(
page([reviewItem('first')], { ...partial, hasMore: true, nextCursor: 'partial-page' }),
)
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValueOnce(page([reviewItem('older')], partial));
const slice = createInboxListSlice(request, vi.fn());
await slice.fetchList();
await slice.loadMore();
expect(slice.nextCursor).toBe('partial-page');
expect(slice.items).toHaveLength(1);
await slice.retry();
expect(request).toHaveBeenLastCalledWith('partial-page');
expect(slice.items).toHaveLength(2);
expect(slice.partial).toBe(true);
});
});
describe('shared Inbox state', () => {
it('keeps requests for Open and Closed independent', async () => {
const open = createDeferredPromise<ListInboxResponse>();
waitingOrClosedRequest.mockImplementation(async (_context, query) =>
query.state === 'open' ? await open.promise : page([reviewItem('closed')]),
);
const store = useInboxStore();
const request = store.refreshListAndSummary();
await store.setActiveTab('closed');
open.resolve(page([reviewItem('open')]));
await request;
expect(store.activeTab).toBe('closed');
expect(store.lists.closed.items[0].id).toBe('closed');
expect(store.lists.waiting.items[0].id).toBe('open');
});
it('treats a failed count as unknown while the list remains usable', async () => {
waitingOrClosedRequest.mockResolvedValue(page([reviewItem('first')]));
vi.mocked(api.fetchInboxSummary).mockResolvedValue({
counts: null,
partial: true,
failedSources: ['workflow_review'],
disabledSources: [],
});
const store = useInboxStore();
await store.refreshListAndSummary();
expect(store.lists.waiting.items).toHaveLength(1);
expect(store.openCount).toBeNull();
});
it('clears disabled-source rows from both tabs even when all reads fail', async () => {
const store = useInboxStore();
store.lists.waiting.items = [reviewItem('open')];
store.lists.closed.items = [reviewItem('closed')];
waitingOrClosedRequest.mockRejectedValue(
new ResponseError('unavailable', {
httpStatusCode: 503,
meta: { disabledSources: ['workflow_review'], failedSources: [] },
}),
);
await store.refreshListAndSummary();
expect(store.lists.waiting.items).toEqual([]);
expect(store.lists.closed.items).toEqual([]);
expect(store.disabledSources).toEqual(['workflow_review']);
});
});
it('allows a source to recover on a fresh page without a settings reload', async () => {
const store = useInboxStore();
waitingOrClosedRequest
.mockResolvedValueOnce(page([], { disabledSources: ['workflow_review'] }))
.mockResolvedValueOnce(page([reviewItem('recovered')]));
await store.refreshListAndSummary();
expect(store.disabledSources).toContain('workflow_review');
await store.refreshListAndSummary();
expect(store.disabledSources).not.toContain('workflow_review');
expect(store.lists.waiting.items[0].id).toBe('recovered');
});
it('starts a new cursor chain to recover a failed source after paging', async () => {
const partial = { partial: true, failedSources: ['workflow_review' as const] };
const request = vi
.fn()
.mockResolvedValueOnce(
page([reviewItem('first')], { ...partial, hasMore: true, nextCursor: 'partial-second' }),
)
.mockResolvedValueOnce(
page([reviewItem('older')], { ...partial, hasMore: true, nextCursor: 'partial-third' }),
)
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValueOnce(
page([reviewItem('recovered')], { hasMore: true, nextCursor: 'complete-second' }),
);
const slice = createInboxListSlice(request, vi.fn());
await slice.fetchList();
await slice.loadMore();
await slice.fetchList();
expect(slice.items).toEqual([]);
expect(slice.nextCursor).toBeNull();
await slice.retry();
expect(request).toHaveBeenLastCalledWith();
expect(slice.items.map((item) => item.id)).toEqual(['recovered']);
expect(slice.nextCursor).toBe('complete-second');
expect(slice.partial).toBe(false);
});
it('requests the two Open categories and omits category for Closed', async () => {
const store = useInboxStore();
await store.refreshListAndSummary();
expect(api.fetchInbox).toHaveBeenCalledWith(expect.anything(), {
state: 'open',
category: 'waiting',
limit: 15,
cursor: undefined,
});
expect(api.fetchInbox).toHaveBeenCalledWith(expect.anything(), {
state: 'open',
category: 'authored',
limit: 15,
cursor: undefined,
});
await store.setActiveTab('closed');
expect(api.fetchInbox).toHaveBeenLastCalledWith(expect.anything(), {
state: 'closed',
category: undefined,
limit: 15,
cursor: undefined,
});
});
it('keeps each Open group cursor and retry independent', async () => {
waitingOrClosedRequest.mockResolvedValueOnce(
page([reviewItem('waiting')], { hasMore: true, nextCursor: 'waiting-2' }),
);
authoredRequest
.mockResolvedValueOnce(
page([reviewItem('authored')], { hasMore: true, nextCursor: 'authored-2' }),
)
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValueOnce(page([reviewItem('older')]));
const store = useInboxStore();
await store.refreshListAndSummary();
await store.lists.authored.loadMore();
expect(store.lists.waiting.error).toBeNull();
expect(store.lists.waiting.nextCursor).toBe('waiting-2');
expect(store.lists.authored.nextCursor).toBe('authored-2');
await store.lists.authored.retry();
expect(authoredRequest).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ category: 'authored', cursor: 'authored-2' }),
);
expect(waitingOrClosedRequest).toHaveBeenCalledOnce();
expect(store.lists.authored.items.map((item) => item.id)).toEqual(['authored', 'older']);
});
it('requires both groups to be successfully empty before showing the Open empty state', async () => {
const store = useInboxStore();
expect(store.isEmpty).toBe(false);
authoredRequest.mockRejectedValueOnce(new Error('timeout'));
await store.refreshListAndSummary();
expect(store.lists.waiting.hasLoaded).toBe(true);
expect(store.initialLoadFailed).toBe(true);
expect(store.isEmpty).toBe(false);
authoredRequest.mockResolvedValueOnce(
page([], { partial: true, failedSources: ['workflow_review'] }),
);
await store.lists.authored.retry();
expect(store.partial).toBe(true);
expect(store.isEmpty).toBe(false);
await store.lists.authored.fetchList();
expect(store.isEmpty).toBe(true);
});
function reviewItem(id: string): InboxItem {
return {
type: 'workflow_review',
id,
projectId: 'project',
title: 'Review',
workflowName: 'Workflow',
requester: null,
authors: [],
reviewers: [],
state: 'open',
decision: 'pending',
workflowVersionId: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
}
it('updates a review decision without discarding loaded pages', async () => {
const store = useInboxStore();
store.lists.waiting.items = [reviewItem('first'), reviewItem('older')];
store.lists.waiting.nextCursor = 'next-page';
store.reconcileItemChange({
type: 'workflow_review',
id: 'older',
state: 'open',
decision: 'changes_requested',
updatedAt: '2026-01-02T00:00:00.000Z',
});
expect(store.lists.waiting.items).toHaveLength(2);
expect(store.lists.waiting.items[1]).toMatchObject({ decision: 'changes_requested' });
expect(store.lists.waiting.nextCursor).toBe('next-page');
expect(api.fetchInbox).not.toHaveBeenCalled();
});
it('removes an approved review from Open and updates the counts', async () => {
const store = useInboxStore();
store.openCount = 2;
store.closedCount = 1;
store.lists.waiting.items = [reviewItem('review')];
store.reconcileItemChange({
type: 'workflow_review',
id: 'review',
state: 'closed',
decision: 'approved',
});
expect(store.lists.waiting.items).toEqual([]);
expect(store.openCount).toBe(1);
expect(store.closedCount).toBe(2);
});
it('keeps the counts when a late approval arrives after a refresh already closed the review', async () => {
const store = useInboxStore();
store.openCount = 1;
store.closedCount = 2;
store.lists.closed.items = [{ ...reviewItem('review'), state: 'closed', decision: 'approved' }];
store.reconcileItemChange({ type: 'workflow_review', id: 'review', state: 'closed' });
expect(store.openCount).toBe(1);
expect(store.closedCount).toBe(2);
expect(api.fetchInboxSummary).not.toHaveBeenCalled();
});
it('loads the counts when an approved review is not in a loaded page', async () => {
vi.mocked(api.fetchInboxSummary).mockResolvedValueOnce({
counts: { open: 1, closed: 2 },
partial: false,
failedSources: [],
disabledSources: [],
});
const store = useInboxStore();
store.openCount = 4;
store.closedCount = 7;
store.reconcileItemChange({ type: 'workflow_review', id: 'review', state: 'closed' });
await vi.waitFor(() => {
expect(api.fetchInboxSummary).toHaveBeenCalledOnce();
expect(store.openCount).toBe(1);
expect(store.closedCount).toBe(2);
});
});

View File

@ -0,0 +1,305 @@
import type {
InboxCategory,
InboxItem,
InboxSourceType,
InboxState,
ListInboxResponse,
} from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { defineStore } from 'pinia';
import { computed, reactive, ref } from 'vue';
import { fetchInbox, fetchInboxSummary } from './inbox.api';
import { INBOX_PAGE_LIMIT, type InboxItemChange, type InboxSectionKey } from './inbox.constants';
import { toError } from './reviews/workflowReviews.utils';
const sourceTypes: InboxSourceType[] = ['workflow_review'];
type ListState = {
items: InboxItem[];
nextCursor: string | null;
hasMore: boolean;
hasLoaded: boolean;
loading: boolean;
loadingMore: boolean;
error: Error | null;
failedRequest: 'list' | 'loadMore' | null;
partial: boolean;
};
function isSourceType(value: unknown): value is InboxSourceType {
return value === 'workflow_review';
}
function disabledTypesFromError(error: unknown): InboxSourceType[] {
const types = error instanceof ResponseError ? error.meta?.disabledSources : undefined;
return Array.isArray(types) ? types.filter(isSourceType) : [];
}
export function createInboxListSlice(
requestPage: (cursor?: string) => Promise<ListInboxResponse>,
onDisabledSources: (types: InboxSourceType[]) => void,
onPage?: (page: ListInboxResponse, append: boolean) => void,
) {
const state = reactive<ListState>({
items: [],
nextCursor: null,
hasMore: false,
hasLoaded: false,
loading: false,
loadingMore: false,
error: null,
failedRequest: null,
partial: false,
});
let requestSeq = 0;
function applyResponse(page: ListInboxResponse, append: boolean) {
onPage?.(page, append);
onDisabledSources(page.disabledSources);
state.hasLoaded = true;
state.items = append ? [...state.items, ...page.data] : page.data;
state.nextCursor = page.nextCursor;
state.hasMore = page.hasMore;
state.partial = page.partial;
}
async function fetchList() {
const seq = ++requestSeq;
state.items = [];
state.nextCursor = null;
state.hasMore = false;
state.loading = true;
state.loadingMore = false;
state.error = null;
state.failedRequest = null;
try {
const page = await requestPage();
if (seq === requestSeq) applyResponse(page, false);
} catch (error) {
if (seq !== requestSeq) return;
onDisabledSources(disabledTypesFromError(error));
state.error = toError(error);
state.failedRequest = 'list';
} finally {
if (seq === requestSeq) state.loading = false;
}
}
async function loadMore() {
if (state.loading || state.loadingMore || !state.hasMore || !state.nextCursor) return;
const seq = ++requestSeq;
state.loadingMore = true;
state.error = null;
state.failedRequest = null;
try {
const page = await requestPage(state.nextCursor);
if (seq === requestSeq) applyResponse(page, true);
} catch (error) {
if (seq !== requestSeq) return;
onDisabledSources(disabledTypesFromError(error));
state.error = toError(error);
state.failedRequest = 'loadMore';
} finally {
if (seq === requestSeq) state.loadingMore = false;
}
}
function removeSources(types: InboxSourceType[]) {
state.items = state.items.filter((item) => !types.includes(item.type));
}
function reset() {
requestSeq++;
state.items = [];
state.hasLoaded = false;
state.nextCursor = null;
state.hasMore = false;
state.loading = false;
state.loadingMore = false;
state.error = null;
state.failedRequest = null;
state.partial = false;
}
return Object.assign(state, {
fetchList,
loadMore,
removeSources,
reset,
async retry() {
if (state.failedRequest === 'loadMore') await loadMore();
else await fetchList();
},
});
}
export const useInboxStore = defineStore('inbox', () => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const activeTab = ref<InboxState>('open');
const openCount = ref<number | null>(null);
const closedCount = ref<number | null>(null);
const disabledSources = ref<InboxSourceType[]>([]);
const enabled = computed(() => settingsStore.settings.inbox?.enabled === true);
let summaryRequestSeq = 0;
function removeDisabledSources(types: InboxSourceType[]) {
if (types.length === 0) return;
disabledSources.value = [...new Set([...disabledSources.value, ...types])];
for (const list of Object.values(lists)) list.removeSources(types);
}
function reconcileSources(page: ListInboxResponse, partialSourceSet: boolean) {
if (partialSourceSet) {
const returnedTypes = page.data.map((item) => item.type);
disabledSources.value = disabledSources.value.filter(
(type) => page.disabledSources.includes(type) || !returnedTypes.includes(type),
);
return;
}
const settings = settingsStore.settings.inbox;
const knownTypes = new Set([
...(settings?.availableTypes ?? []),
...(settings?.failedTypes ?? []),
...page.data.map((item) => item.type),
]);
disabledSources.value = sourceTypes.filter(
(type) =>
!knownTypes.has(type) ||
page.disabledSources.includes(type) ||
(disabledSources.value.includes(type) && page.failedSources.includes(type)),
);
}
function requestPage(state: InboxState, category?: InboxCategory) {
return async (cursor?: string) =>
await fetchInbox(rootStore.restApiContext, {
state,
category,
limit: INBOX_PAGE_LIMIT,
cursor,
});
}
const lists = {
waiting: createInboxListSlice(
requestPage('open', 'waiting'),
removeDisabledSources,
reconcileSources,
),
// A category can query only part of the source set.
authored: createInboxListSlice(requestPage('open', 'authored'), removeDisabledSources, (page) =>
reconcileSources(page, true),
),
closed: createInboxListSlice(requestPage('closed'), removeDisabledSources, reconcileSources),
};
const activeSectionKeys = computed<InboxSectionKey[]>(() =>
activeTab.value === 'closed' ? ['closed'] : ['waiting', 'authored'],
);
const activeLists = computed(() => activeSectionKeys.value.map((key) => lists[key]));
const hasItems = computed(() => activeLists.value.some((list) => list.items.length > 0));
const loading = computed(() => activeLists.value.some((list) => list.loading));
const initialLoadFailed = computed(() =>
activeLists.value.some((list) => list.failedRequest === 'list'),
);
const partial = computed(() => activeLists.value.some((list) => list.partial));
const isEmpty = computed(() =>
activeLists.value.every(
(list) =>
list.hasLoaded &&
!list.loading &&
!list.error &&
!list.partial &&
!list.hasMore &&
list.items.length === 0,
),
);
async function fetchSummary() {
if (!enabled.value) return;
const seq = ++summaryRequestSeq;
try {
const summary = await fetchInboxSummary(rootStore.restApiContext);
if (seq !== summaryRequestSeq) return;
removeDisabledSources(summary.disabledSources);
openCount.value = summary.counts?.open ?? null;
closedCount.value = summary.counts?.closed ?? null;
} catch (error) {
if (seq !== summaryRequestSeq) return;
removeDisabledSources(disabledTypesFromError(error));
}
}
async function fetchActiveTab() {
if (!enabled.value) return;
await Promise.all(activeLists.value.map(async (list) => await list.fetchList()));
}
async function refreshListAndSummary() {
await Promise.all([fetchActiveTab(), fetchSummary()]);
}
function reconcileItemChange(change: InboxItemChange) {
let previousState: InboxState | undefined;
for (const [key, list] of Object.entries(lists)) {
const item = list.items.find((row) => row.type === change.type && row.id === change.id);
if (!item) continue;
previousState ??= item.state;
if (change.state) item.state = change.state;
if (change.updatedAt) item.updatedAt = change.updatedAt;
if (item.type === 'workflow_review' && change.decision) item.decision = change.decision;
if (item.state !== (key === 'closed' ? 'closed' : 'open')) {
list.items = list.items.filter((row) => row !== item);
}
}
if (change.state !== 'closed') return;
// A late result can arrive after a summary that already counts it.
// Count only a row that closes now. Without a row, ask the server.
if (previousState === 'open') {
if (openCount.value !== null) openCount.value = Math.max(0, openCount.value - 1);
if (closedCount.value !== null) closedCount.value++;
} else if (!previousState) {
void fetchSummary();
}
}
async function setActiveTab(tab: InboxState) {
if (activeTab.value === tab) return;
activeTab.value = tab;
await fetchActiveTab();
}
function reset() {
summaryRequestSeq++;
for (const list of Object.values(lists)) list.reset();
openCount.value = null;
closedCount.value = null;
const settings = settingsStore.settings.inbox;
const available = [...(settings?.availableTypes ?? []), ...(settings?.failedTypes ?? [])];
disabledSources.value = sourceTypes.filter((type) => !available.includes(type));
}
return {
lists,
activeTab,
activeSectionKeys,
activeLists,
hasItems,
loading,
initialLoadFailed,
partial,
isEmpty,
enabled,
openCount,
closedCount,
disabledSources,
fetchSummary,
fetchActiveTab,
refreshListAndSummary,
reconcileItemChange,
setActiveTab,
reset,
};
});

View File

@ -0,0 +1,5 @@
export { InboxModule } from './inbox.module';
export { INBOX_VIEW } from './inbox.constants';
export { isInboxRoute } from './inbox.routes';
export { WORKFLOW_REVIEW_REQUESTS_VIEW, REVIEW_INBOX_QUERY_PARAM } from './reviews/constants';
export { formatUserDisplayName } from './reviews/workflowReviews.utils';

View File

@ -0,0 +1,647 @@
import type {
DecideWorkflowReviewRequestResponse,
InboxWorkflowReviewItem,
WorkflowReviewRequestDetail,
} from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import { createComponentRenderer, mockedStore, waitAllPromises } from '@n8n/frontend-test-utils';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { useReviewActivityStore } from './reviewActivity.store';
import { useReviewDetailStore } from './reviewDetail.store';
import WorkflowReviewDetail from './WorkflowReviewDetail.vue';
import * as api from './workflowReviews.api';
vi.mock('./workflowReviews.api');
vi.mock('@n8n/composables/useToast', () => ({ useToast: vi.fn() }));
vi.mock('./components/WorkflowReviewChangesSection.vue', () => ({
default: { template: '<div data-test-id="workflow-review-changes-section" />' },
}));
const showError = vi.fn();
const showMessage = vi.fn();
const onItemChange = vi.fn();
let isInboxActive = true;
let selectedReviewId: string | null = 'req-1';
const renderComponent = createComponentRenderer(WorkflowReviewDetail, {
props: {
reviewId: 'req-1',
onItemChange,
isActive: () => isInboxActive,
isSelected: (id: string) => selectedReviewId === id,
},
global: {
stubs: {
WorkflowReviewDecisionPopover: {
props: ['deciding', 'viewerCanDecide', 'viewerCanComment', 'ineligibilityHint'],
template: `<div data-test-id="workflow-review-decision-popover" :data-deciding="deciding">
<button data-test-id="approve-review" @click="$emit('decide', { decision: 'approved' })" />
<button data-test-id="request-changes" @click="$emit('decide', { decision: 'changes_requested', note: 'needs work' })" />
<button data-test-id="comment-posted" @click="$emit('comment-posted')" />
</div>`,
},
},
},
});
const decisionResponse = (
overrides: Partial<DecideWorkflowReviewRequestResponse> = {},
): DecideWorkflowReviewRequestResponse => ({
id: 'req-1',
state: 'closed',
decision: 'approved',
workflowVersionId: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
...overrides,
});
let reviewStore: ReturnType<typeof mockedStore<typeof useReviewDetailStore>>;
let activityStore: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
beforeEach(() => {
createTestingPinia();
isInboxActive = true;
selectedReviewId = 'req-1';
vi.clearAllMocks();
vi.mocked(useToast).mockReturnValue({ showError, showMessage } as unknown as ReturnType<
typeof useToast
>);
reviewStore = mockedStore(useReviewDetailStore);
reviewStore.fetchDetail.mockResolvedValue(undefined);
reviewStore.detail = createDetail();
reviewStore.decideOnReview.mockResolvedValue(decisionResponse());
activityStore = mockedStore(useReviewActivityStore);
activityStore.currentReviewId = 'req-1';
activityStore.fetchFeed.mockResolvedValue(undefined);
});
describe('review decisions', () => {
it('offers no decision actions for a closed review', async () => {
reviewStore.detail = createDetail({ state: 'closed', decision: 'approved' });
const { queryByTestId } = renderComponent();
await waitAllPromises();
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
});
it('submits an approval for the selected review', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(reviewStore.decideOnReview).toHaveBeenCalledWith('req-1', { decision: 'approved' });
expect(showError).not.toHaveBeenCalled();
});
it('refetches the detail after a decision on the selected review', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
reviewStore.fetchDetail.mockClear();
getByTestId('approve-review').click();
await waitAllPromises();
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
});
it('submits a change request for the selected review', async () => {
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('request-changes').click();
await waitAllPromises();
expect(reviewStore.decideOnReview).toHaveBeenCalledWith('req-1', {
decision: 'changes_requested',
note: 'needs work',
});
});
it('shows a success toast when the approval published the workflow', async () => {
reviewStore.decideOnReview.mockResolvedValueOnce(
decisionResponse({ autoPublish: { status: 'published' } }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showMessage).toHaveBeenCalledWith({
type: 'success',
title: 'Review approved',
message: 'The reviewed workflow version has been published.',
});
expect(showError).not.toHaveBeenCalled();
});
it('shows a warning toast with the reason when the auto-publish failed', async () => {
reviewStore.decideOnReview.mockResolvedValueOnce(
decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showMessage).toHaveBeenCalledWith({
type: 'warning',
duration: 0,
title: 'Review approved, but the workflow is not published',
message: 'Version not found. Publish the workflow manually to retry.',
});
expect(showError).not.toHaveBeenCalled();
});
it('does not double up punctuation on an already-terminated message', async () => {
reviewStore.decideOnReview.mockResolvedValueOnce(
decisionResponse({
autoPublish: { status: 'failed', message: 'Cannot activate an archived workflow.' },
}),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Cannot activate an archived workflow. Publish the workflow manually to retry.',
}),
);
});
it('shows no publish toast when requesting changes', async () => {
reviewStore.decideOnReview.mockResolvedValueOnce(
decisionResponse({ state: 'open', decision: 'changes_requested' }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
reviewStore.fetchDetail.mockClear();
getByTestId('request-changes').click();
await waitAllPromises();
expect(showMessage).not.toHaveBeenCalled();
expect(reviewStore.fetchDetail).not.toHaveBeenCalled();
});
it('shows an error toast when the decision fails', async () => {
const error = new Error('forbidden');
reviewStore.decideOnReview.mockRejectedValueOnce(error);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await waitAllPromises();
expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision');
});
it('refreshes detail and activity and reports a failed decision', async () => {
reviewStore.decideOnReview.mockRejectedValueOnce(new Error('conflict'));
const { getByTestId } = renderComponent();
await waitAllPromises();
reviewStore.fetchDetail.mockClear();
activityStore.fetchFeed.mockClear();
getByTestId('approve-review').click();
await waitAllPromises();
expect(onItemChange).toHaveBeenCalledWith({ type: 'workflow_review', id: 'req-1' });
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
// The other reviewer's decision entry is in the feed, not in the detail payload.
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
// The note stays, so the reviewer can retry with it.
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
});
it('shows the submitted decision in the feed and drops the note behind it', async () => {
reviewStore.decideOnReview.mockResolvedValueOnce(
decisionResponse({ state: 'open', decision: 'changes_requested' }),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
activityStore.fetchFeed.mockClear();
getByTestId('request-changes').click();
await waitAllPromises();
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
// The note it submitted, so a note typed while the decision was in flight survives.
expect(activityStore.clearDecisionNote).toHaveBeenCalledWith('needs work');
});
it('leaves the feed alone when the decision lands after the viewer moved on', async () => {
let resolveDecision!: () => void;
reviewStore.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
resolveDecision = () =>
resolve(decisionResponse({ state: 'open', decision: 'changes_requested' }));
}),
);
const { getByTestId, rerender } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
selectedReviewId = 'req-2';
await rerender({ reviewId: 'req-2' });
await waitAllPromises();
activityStore.fetchFeed.mockClear();
resolveDecision();
await waitAllPromises();
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
});
it('shows no publish toast once the viewer has left the page', async () => {
let resolveDecision!: () => void;
reviewStore.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
resolveDecision = () =>
resolve(
decisionResponse({
autoPublish: { status: 'failed', message: 'Version not found' },
}),
);
}),
);
const { getByTestId, unmount } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
isInboxActive = false;
unmount();
resolveDecision();
await waitAllPromises();
expect(showMessage).not.toHaveBeenCalled();
});
it.each(['another review', 'no review'])(
'reports a publication failure after the viewer selects %s',
async (selection) => {
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
const { getByTestId, rerender, unmount } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
if (selection === 'another review') {
selectedReviewId = 'req-2';
await rerender({ reviewId: 'req-2' });
} else {
selectedReviewId = null;
unmount();
}
await waitAllPromises();
activityStore.fetchFeed.mockClear();
pending.resolve(
decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }),
);
await waitAllPromises();
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
duration: 0,
message: 'Version not found. Publish the workflow manually to retry.',
}),
);
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
},
);
it('reports a failed decision after the viewer selects another review', async () => {
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
const { getByTestId, rerender } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
selectedReviewId = 'req-2';
activityStore.currentReviewId = 'req-2';
await rerender({ reviewId: 'req-2' });
await waitAllPromises();
activityStore.fetchFeed.mockClear();
reviewStore.fetchDetail.mockClear();
const error = new Error('conflict');
pending.reject(error);
await waitAllPromises();
expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision');
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-2');
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
});
it('shows no failure toast once the viewer has left the page', async () => {
let rejectDecision!: (error: Error) => void;
reviewStore.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((_resolve, reject) => {
rejectDecision = reject;
}),
);
const { getByTestId, unmount } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
isInboxActive = false;
unmount();
rejectDecision(new Error('conflict'));
await waitAllPromises();
expect(showError).not.toHaveBeenCalled();
expect(onItemChange).toHaveBeenCalledWith({ type: 'workflow_review', id: 'req-1' });
});
it('locks the decision actions while a decision is in flight', async () => {
let resolveDecision!: () => void;
reviewStore.decideOnReview.mockImplementationOnce(
async () =>
await new Promise<DecideWorkflowReviewRequestResponse>((resolve) => {
resolveDecision = () => resolve(decisionResponse());
}),
);
const { getByTestId } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
await vi.waitFor(() => {
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
'data-deciding',
'true',
);
});
resolveDecision();
await waitAllPromises();
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
'data-deciding',
'false',
);
});
it('keeps decisions locked after selection changes until the submitted action completes', async () => {
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
const { getByTestId, rerender } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
reviewStore.detail = createDetail({ id: 'req-2' });
selectedReviewId = 'req-2';
await rerender({ reviewId: 'req-2' });
await waitAllPromises();
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
'data-deciding',
'true',
);
getByTestId('approve-review').click();
expect(reviewStore.decideOnReview).toHaveBeenCalledOnce();
pending.resolve(decisionResponse());
await waitAllPromises();
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
'data-deciding',
'false',
);
});
});
describe('review detail ownership', () => {
it('loads the selected review and activity on entry', async () => {
renderComponent();
await waitAllPromises();
expect(reviewStore.clearDetail).toHaveBeenCalledOnce();
expect(activityStore.reset).toHaveBeenCalledOnce();
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
});
it('uses the list title while detail is loading', () => {
reviewStore.detail = null;
reviewStore.detailLoading = true;
const { getByTestId } = renderComponent({ props: { listItem: createInboxItem() } });
expect(getByTestId('workflow-review-request-title')).toHaveTextContent('List review');
});
it('keeps the list title and row when detail reports a missing review', async () => {
const { getByTestId } = renderComponent();
reviewStore.detailNotFound = true;
await waitAllPromises();
expect(onItemChange).not.toHaveBeenCalled();
expect(getByTestId('workflow-review-detail-not-found')).toBeInTheDocument();
expect(getByTestId('workflow-review-request-title')).toBeInTheDocument();
});
it('shows the Inbox fallback after a failed detail load without a list item', async () => {
reviewStore.detail = null;
const error = new Error('timeout');
reviewStore.fetchDetail.mockRejectedValueOnce(error);
const { getByTestId } = renderComponent({
slots: { default: '<div data-test-id="inbox-fallback">Select an item</div>' },
});
await waitAllPromises();
expect(getByTestId('inbox-fallback')).toBeInTheDocument();
expect(showError).toHaveBeenCalledWith(error, 'Could not load workflow reviews');
});
it('refreshes the current review after returning to it and keeps its new note', async () => {
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
reviewStore.clearDetail.mockImplementation(() => {
reviewStore.selectionRevision++;
});
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
const { getByTestId, rerender } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
selectedReviewId = 'req-2';
await rerender({ reviewId: 'req-2' });
selectedReviewId = 'req-1';
await rerender({ reviewId: 'req-1' });
activityStore.decisionNote = 'New note';
activityStore.fetchFeed.mockClear();
pending.resolve(decisionResponse({ autoPublish: { status: 'published' } }));
await waitAllPromises();
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
expect(activityStore.decisionNote).toBe('New note');
expect(onItemChange).toHaveBeenCalledWith({
type: 'workflow_review',
id: 'req-1',
state: 'closed',
decision: 'approved',
updatedAt: '2024-01-02T00:00:00.000Z',
});
});
it.each(['loaded', 'loading'])(
'shows closed detail and saved activity after returning while approval is pending and detail is %s',
async (detailState) => {
createTestingPinia({ stubActions: false });
reviewStore = mockedStore(useReviewDetailStore);
activityStore = mockedStore(useReviewActivityStore);
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
const detailRequest = createDeferredPromise<WorkflowReviewRequestDetail>();
vi.mocked(api.decideWorkflowReviewRequest).mockReturnValueOnce(pending.promise);
vi.mocked(api.fetchWorkflowReviewRequestDetail).mockImplementation(async (_context, id) =>
createDetail({ id }),
);
vi.mocked(api.fetchWorkflowReviewActivity).mockResolvedValue({
data: [],
nextCursor: null,
hasMore: false,
});
const { getByTestId, queryByTestId, rerender } = renderComponent();
await waitAllPromises();
getByTestId('approve-review').click();
selectedReviewId = 'req-2';
await rerender({ reviewId: 'req-2' });
await waitAllPromises();
if (detailState === 'loading') {
vi.mocked(api.fetchWorkflowReviewRequestDetail).mockReturnValueOnce(detailRequest.promise);
}
selectedReviewId = 'req-1';
await rerender({ reviewId: 'req-1' });
await waitAllPromises();
activityStore.decisionNote = 'New note';
vi.mocked(api.fetchWorkflowReviewRequestDetail).mockResolvedValue(
createDetail({ state: 'closed', decision: 'approved' }),
);
vi.mocked(api.fetchWorkflowReviewActivity).mockResolvedValue({
data: [
{
id: '2',
type: 'review.approved',
typeVersion: 1,
data: null,
createdBy: null,
createdAt: '2024-01-02T00:00:00.000Z',
},
],
nextCursor: null,
hasMore: false,
});
pending.resolve(decisionResponse());
await waitAllPromises();
detailRequest.resolve(createDetail());
await waitAllPromises();
expect(reviewStore.detail?.state).toBe('closed');
expect(activityStore.entries).toEqual([expect.objectContaining({ type: 'review.approved' })]);
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
expect(activityStore.decisionNote).toBe('New note');
},
);
it('reports a failed comment after the viewer switches from Activity to Changes', async () => {
const pending = createDeferredPromise<boolean>();
activityStore.postComment.mockReturnValueOnce(pending.promise);
activityStore.draft = 'Sent comment';
const { getByTestId, queryByTestId, rerender } = renderComponent();
await waitAllPromises();
await userEvent.click(getByTestId('send-message-button'));
await rerender({ tab: 'changes' });
expect(queryByTestId('workflow-review-comment-composer')).not.toBeInTheDocument();
const error = new Error('timeout');
pending.reject(error);
await waitAllPromises();
expect(showError).toHaveBeenCalledWith(error, 'Could not post comment');
expect(activityStore.draft).toBe('Sent comment');
});
it('refreshes a reselected review after its prior detail entry unmounts', async () => {
const pending = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
reviewStore.clearDetail.mockImplementation(() => {
reviewStore.selectionRevision++;
});
reviewStore.decideOnReview.mockReturnValueOnce(pending.promise);
const first = renderComponent();
await waitAllPromises();
first.getByTestId('approve-review').click();
first.unmount();
renderComponent();
await waitAllPromises();
activityStore.decisionNote = 'New note';
activityStore.fetchFeed.mockClear();
reviewStore.fetchDetail.mockClear();
pending.resolve(
decisionResponse({ autoPublish: { status: 'failed', message: 'Not published' } }),
);
await waitAllPromises();
expect(onItemChange).toHaveBeenCalledWith({
type: 'workflow_review',
id: 'req-1',
state: 'closed',
decision: 'approved',
updatedAt: '2024-01-02T00:00:00.000Z',
});
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1');
expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1');
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' }));
expect(activityStore.decisionNote).toBe('New note');
});
it('does not reset shared stores when an older layout unmounts', async () => {
reviewStore.clearDetail.mockImplementation(() => {
reviewStore.selectionRevision++;
});
const first = renderComponent();
renderComponent();
await waitAllPromises();
activityStore.draft = 'Current draft';
reviewStore.clearDetail.mockClear();
activityStore.reset.mockClear();
first.unmount();
expect(reviewStore.clearDetail).not.toHaveBeenCalled();
expect(activityStore.reset).not.toHaveBeenCalled();
expect(activityStore.draft).toBe('Current draft');
});
it('ignores an old detail failure after a new layout claims the same review', async () => {
const pending = createDeferredPromise<undefined>();
reviewStore.clearDetail.mockImplementation(() => {
reviewStore.selectionRevision++;
});
reviewStore.fetchDetail.mockReturnValueOnce(pending.promise);
renderComponent();
renderComponent();
await waitAllPromises();
pending.reject(new Error('timeout'));
await waitAllPromises();
expect(showError).not.toHaveBeenCalled();
});
});
function createInboxItem(): InboxWorkflowReviewItem {
return {
type: 'workflow_review',
id: 'req-1',
projectId: 'proj-1',
title: 'List review',
workflowName: 'My workflow',
workflowVersionId: null,
requester: null,
authors: [],
reviewers: [],
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
}
function createDetail(
overrides: Partial<WorkflowReviewRequestDetail> = {},
): WorkflowReviewRequestDetail {
return {
...createInboxItem(),
description: null,
workflows: [],
viewerCanDecide: true,
viewerDecisionIneligibilityReason: null,
viewerCanComment: true,
...overrides,
};
}

View File

@ -0,0 +1,257 @@
<script setup lang="ts">
import type { InboxWorkflowReviewItem } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import { N8nEmptyState, N8nHeading, N8nLoading } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import type { InboxItemChange } from '../inbox.constants';
import WorkflowReviewDetailTabs from './components/WorkflowReviewDetailTabs.vue';
import type { WorkflowReviewDetailTab } from './components/WorkflowReviewDetailTabs.vue';
import WorkflowReviewStatusDot from './components/WorkflowReviewStatusDot.vue';
import { useReviewActivityStore } from './reviewActivity.store';
import { useReviewDetailStore } from './reviewDetail.store';
import type { WorkflowReviewDecisionInput } from './workflowReviews.api';
const props = withDefaults(
defineProps<{
reviewId: string;
listItem?: InboxWorkflowReviewItem;
tab?: WorkflowReviewDetailTab;
onItemChange: (change: InboxItemChange) => void;
isActive: () => boolean;
isSelected: (reviewId: string) => boolean;
}>(),
{ tab: 'activity' },
);
const emit = defineEmits<{ 'update:tab': [tab: WorkflowReviewDetailTab] }>();
const reviewStore = useReviewDetailStore();
const activityStore = useReviewActivityStore();
const { detail, detailLoading, detailNotFound, deciding } = storeToRefs(reviewStore);
const i18n = useI18n();
const { showError, showMessage } = useToast();
const alertIcon = { type: 'icon', value: 'circle-alert' } as const;
const review = computed(() => {
if (detail.value?.id === props.reviewId) {
return detail.value;
}
return props.listItem?.id === props.reviewId ? props.listItem : null;
});
let isMounted = false;
let selectionRevision = 0;
function ownsSelection(id: string, revision = selectionRevision) {
return (
isMounted &&
props.reviewId === id &&
revision === selectionRevision &&
revision === reviewStore.selectionRevision
);
}
async function loadDetail(id: string, revision: number) {
try {
await reviewStore.fetchDetail(id);
} catch (error) {
if (props.isActive() && props.isSelected(id) && revision === reviewStore.selectionRevision) {
showError(error, i18n.baseText('workflowReviews.error.load'));
}
}
}
watch(
() => props.reviewId,
(id) => {
// A new entry owns these stores. An older layout must not reset them on unmount.
reviewStore.clearDetail();
activityStore.reset();
selectionRevision = reviewStore.selectionRevision;
const revision = selectionRevision;
void loadDetail(id, revision);
void activityStore.fetchFeed(id);
},
{ immediate: true },
);
function asSentence(message: string) {
const trimmed = message.trim();
return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
}
async function onDecide(input: WorkflowReviewDecisionInput) {
const id = props.reviewId;
const revision = selectionRevision;
if (!ownsSelection(id, revision) || deciding.value) {
return;
}
// The Inbox can reconcile this result after the viewer selects another source.
const onItemChange = props.onItemChange;
const isActive = props.isActive;
const isSelected = props.isSelected;
deciding.value = true;
try {
const { autoPublish, state, decision, updatedAt } = await reviewStore.decideOnReview(id, input);
onItemChange({ type: 'workflow_review', id, state, decision, updatedAt });
if (ownsSelection(id, revision)) {
activityStore.clearDecisionNote(input.note ?? '');
}
if (isSelected(id)) {
void activityStore.fetchFeed(id);
if (state === 'closed' || detail.value?.id !== id) {
void loadDetail(id, reviewStore.selectionRevision);
}
}
if (!isActive()) {
return;
}
if (autoPublish?.status === 'published') {
showMessage({
type: 'success',
title: i18n.baseText('workflowReviews.decision.approved.published.title'),
message: i18n.baseText('workflowReviews.decision.approved.published.message'),
});
} else if (autoPublish?.status === 'failed') {
showMessage({
type: 'warning',
duration: 0,
title: i18n.baseText('workflowReviews.decision.approved.publishFailed.title'),
message: i18n.baseText('workflowReviews.decision.approved.publishFailed.message', {
interpolate: { message: asSentence(autoPublish.message) },
}),
});
}
} catch (error) {
onItemChange({ type: 'workflow_review', id });
if (!isActive()) {
return;
}
showError(error, i18n.baseText('workflowReviews.decision.error.title'));
const selectedId = activityStore.currentReviewId;
if (selectedId && isSelected(selectedId)) {
// Another reviewer can decide first. Keep the note while loading the current result.
await Promise.all([
loadDetail(selectedId, reviewStore.selectionRevision),
selectedId === id ? activityStore.fetchFeed(id) : undefined,
]);
}
} finally {
deciding.value = false;
}
}
onMounted(() => {
isMounted = true;
});
onBeforeUnmount(() => {
isMounted = false;
});
</script>
<template>
<section :class="$style.detail" data-test-id="workflow-review-detail">
<div :class="$style.columnTitle">
<div
v-if="review"
:class="$style.reviewTitle"
data-test-id="workflow-review-request-title-row"
>
<WorkflowReviewStatusDot :state="review.state" :decision="review.decision" />
<N8nHeading bold tag="h2" size="xlarge" data-test-id="workflow-review-request-title">{{
review.title
}}</N8nHeading>
</div>
</div>
<div :class="$style.mainBody">
<div
v-if="detailNotFound"
:class="$style.emptyStateWrapper"
data-test-id="workflow-review-detail-not-found"
>
<N8nEmptyState
:class="$style.emptyState"
:icon="alertIcon"
:heading="i18n.baseText('workflowReviews.detail.notFound.title')"
:description="i18n.baseText('workflowReviews.detail.notFound.body')"
/>
</div>
<div v-else-if="detailLoading" :class="$style.detailSkeleton">
<N8nLoading :loading="true" :rows="3" />
</div>
<WorkflowReviewDetailTabs
v-else-if="review"
:review="review"
:tab="tab"
:deciding="deciding"
@update:tab="emit('update:tab', $event)"
@decide="onDecide"
/>
<slot v-else />
</div>
</section>
</template>
<style lang="scss" module>
.detail {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.columnTitle {
display: flex;
align-items: center;
min-height: var(--spacing--2xl);
padding-bottom: var(--spacing--sm);
}
.reviewTitle {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
}
.mainBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.detailSkeleton {
max-width: var(--review-activity--max-width);
}
.emptyStateWrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyStateWrapper .emptyState {
border: none;
padding: 0;
}
</style>

View File

@ -1,7 +1,6 @@
import type { WorkflowReviewActivityEntry } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils';
import { computed, nextTick, ref } from 'vue';
import {
@ -24,7 +23,7 @@ const observer = vi.hoisted(() => ({
onIntersect: () => {},
}));
vi.mock('@/app/composables/useIntersectionObserver', () => ({
vi.mock('@n8n/composables/useIntersectionObserver', () => ({
useIntersectionObserver: (options: { onIntersect: () => void }) => {
observer.onIntersect = options.onIntersect;
return { observe: observer.observe, disconnect: vi.fn(), observer: { value: null } };
@ -36,8 +35,8 @@ const resize = vi.hoisted(() => ({ onResize: (_entries: ResizeObserverEntry[]) =
vi.mock('@vueuse/core', async (importOriginal) => ({
...(await importOriginal<typeof import('@vueuse/core')>()),
useResizeObserver: (_target: unknown, callback: (entries: ResizeObserverEntry[]) => void) => {
resize.onResize = callback;
useResizeObserver: (_target: unknown, onResize: (entries: ResizeObserverEntry[]) => void) => {
resize.onResize = onResize;
},
}));

View File

@ -5,7 +5,7 @@ import { useResizeObserver } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { inject, onMounted, ref, watch } from 'vue';
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
import { useIntersectionObserver } from '@n8n/composables/useIntersectionObserver';
import { ReviewDetailScrollContainerKey } from '../constants';
import { useReviewActivityStore } from '../reviewActivity.store';

View File

@ -6,34 +6,40 @@ import type {
} from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import type { INode } from 'n8n-workflow';
import { isReactive, reactive } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { IWorkflowDb } from '@/Interface';
import { defineComponent } from 'vue';
import { createComponentRenderer } from '@n8n/frontend-test-utils';
import { componentRegistry, type SlotWorkflowDiffProps } from '@n8n/frontend-module-sdk';
import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
type DiffViewProps = {
sourceWorkflow?: IWorkflowDb;
targetWorkflow?: IWorkflowDb;
sourceLabel?: string;
targetLabel?: string;
};
type DiffViewProps = SlotWorkflowDiffProps;
const { diffViewProps } = vi.hoisted(() => ({
diffViewProps: [] as DiffViewProps[],
}));
vi.mock('@/features/workflows/workflowDiff/WorkflowDiffView.vue', () => ({
default: {
name: 'WorkflowDiffView',
props: ['sourceWorkflow', 'targetWorkflow', 'sourceLabel', 'targetLabel'],
setup(props: DiffViewProps) {
diffViewProps.push(props);
},
template:
'<div data-test-id="workflow-diff-view-stub"><slot name="sourceLabel" /><slot name="targetLabel" /></div>',
const DiffView = defineComponent({
name: 'WorkflowDiffView',
props: [
'sourceSnapshot',
'targetSnapshot',
'workflowId',
'workflowName',
'sourceLabel',
'targetLabel',
'showFullscreenButton',
],
setup(props) {
diffViewProps.push(props as SlotWorkflowDiffProps);
},
}));
template:
'<div data-test-id="workflow-diff-view-stub"><slot name="sourceLabel" /><slot name="targetLabel" /><slot name="sourceEmptyText" /></div>',
});
beforeEach(() => {
componentRegistry.clear();
componentRegistry.register('workflow-diff', DiffView);
});
afterEach(() => componentRegistry.clear());
const renderSection = createComponentRenderer(WorkflowReviewChangesSection);
@ -206,30 +212,26 @@ describe('WorkflowReviewChangesSection', () => {
expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
expect(diffViewProps).toHaveLength(1);
expect(diffViewProps[0].sourceWorkflow).toBeUndefined();
expect(diffViewProps[0].targetWorkflow?.id).toBe('wf-1');
expect(diffViewProps[0].sourceSnapshot).toBeUndefined();
expect(diffViewProps[0].workflowId).toBe('wf-1');
expect(queryByTestId('workflow-review-changes-source-label')).not.toBeInTheDocument();
expect(getByTestId('workflow-review-changes-target-label')).toBeInTheDocument();
});
it('hands the diff detached workflows built from the snapshots', () => {
it('passes workflow identity, snapshots, and labels to the editor renderer', () => {
const workflow = makeWorkflow();
renderComponent({ props: { workflow } });
expect(diffViewProps).toHaveLength(1);
const { sourceWorkflow, targetWorkflow, sourceLabel, targetLabel } = diffViewProps[0];
const { sourceSnapshot, targetSnapshot, sourceLabel, targetLabel } = diffViewProps[0];
expect(sourceWorkflow?.id).toBe('wf-1');
expect(sourceWorkflow?.name).toBe('Payment handler');
expect(sourceWorkflow?.versionId).toBe('0f123890-0000-0000-0000-000000000000');
expect(sourceWorkflow?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']);
expect(diffViewProps[0].workflowId).toBe('wf-1');
expect(diffViewProps[0].workflowName).toBe('Payment handler');
expect(sourceSnapshot?.versionId).toBe('0f123890-0000-0000-0000-000000000000');
expect(sourceSnapshot?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']);
expect(targetWorkflow?.versionId).toBe('77b70644-0000-0000-0000-000000000000');
expect(targetWorkflow?.nodes.map((node) => node.id)).toEqual(['node-1']);
// Detached copies: not sharing references with the DTO, so the diff
// canvases can't feed writes back into store state.
expect(targetWorkflow?.nodes).not.toBe(workflow.pinnedVersion?.nodes);
expect(targetSnapshot?.versionId).toBe('77b70644-0000-0000-0000-000000000000');
expect(targetSnapshot?.nodes.map((node) => node.id)).toEqual(['node-1']);
expect(sourceLabel).toContain('Published');
expect(sourceLabel).toContain('0f123890');
@ -237,23 +239,6 @@ describe('WorkflowReviewChangesSection', () => {
expect(targetLabel).toContain('77b70644');
});
// Feed a genuinely reactive prop, The guard matters — a reactive source lets
// WorkflowDiffView's position writes drive a hydrate → dispose → hydrate loop
// that pins the main thread.
it('detaches the handed-off workflows from a reactive source', () => {
const workflow = reactive(makeWorkflow());
renderComponent({ props: { workflow } });
expect(diffViewProps).toHaveLength(1);
const { sourceWorkflow, targetWorkflow } = diffViewProps[0];
expect(isReactive(workflow.pinnedVersion)).toBe(true);
expect(isReactive(sourceWorkflow)).toBe(false);
expect(isReactive(targetWorkflow)).toBe(false);
expect(isReactive(targetWorkflow?.nodes)).toBe(false);
expect(isReactive(targetWorkflow?.nodes[0])).toBe(false);
});
describe('closed reviews', () => {
const approved = { state: 'closed', decision: 'approved' } as const;
@ -288,7 +273,7 @@ describe('WorkflowReviewChangesSection', () => {
});
expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument();
expect(diffViewProps[0].sourceWorkflow).toBeUndefined();
expect(diffViewProps[0].sourceSnapshot).toBeUndefined();
expect(
queryByTestId('workflow-review-changes-closed-without-approval'),
).not.toBeInTheDocument();

View File

@ -7,14 +7,14 @@ import type {
} from '@n8n/api-types';
import { N8nCallout, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { deepCopy } from 'n8n-workflow';
import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import { computed, markRaw } from 'vue';
import { computed } from 'vue';
import { componentRegistry } from '@n8n/frontend-module-sdk';
import WorkflowDiffView from '@/features/workflows/workflowDiff/WorkflowDiffView.vue';
import { getVersionLabel } from '@/features/workflows/workflowHistory/utils';
import type { IWorkflowDb } from '@/Interface';
import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
const WorkflowDiffView = computed(() => componentRegistry.get('workflow-diff'));
const props = defineProps<{
workflow: WorkflowReviewRequestWorkflowDetail;
@ -58,32 +58,6 @@ const hasChanges = computed(() => {
return !isEqual(contentOf(baselineVersion), contentOf(pinnedVersion));
});
// Hand the diff a fully-detached, non-reactive copy.
function snapshotToWorkflow(snapshot: WorkflowReviewVersionSnapshot): IWorkflowDb {
return markRaw(
deepCopy({
id: props.workflow.workflowId,
name: props.workflow.workflowName,
active: false,
isArchived: false,
createdAt: snapshot.createdAt,
updatedAt: snapshot.createdAt,
versionId: snapshot.versionId,
activeVersionId: null,
nodes: snapshot.nodes,
connections: snapshot.connections,
nodeGroups: snapshot.nodeGroups,
}),
);
}
const sourceWorkflow = computed(() =>
props.workflow.baselineVersion ? snapshotToWorkflow(props.workflow.baselineVersion) : undefined,
);
const targetWorkflow = computed(() =>
props.workflow.pinnedVersion ? snapshotToWorkflow(props.workflow.pinnedVersion) : undefined,
);
// The dots track publish status, which approval flips: the approved version is
// the published one now, and the baseline it replaced no longer is.
const sourceDotClass = computed(() =>
@ -156,9 +130,12 @@ const targetLabel = computed(() =>
{{ noChangesText }}
</N8nCallout>
<div v-else :class="$style.diff" data-test-id="workflow-review-changes-diff">
<WorkflowDiffView
:source-workflow="sourceWorkflow"
:target-workflow="targetWorkflow"
<component
:is="WorkflowDiffView"
:source-snapshot="workflow.baselineVersion ?? undefined"
:workflow-id="workflow.workflowId"
:workflow-name="workflow.workflowName"
:target-snapshot="workflow.pinnedVersion ?? undefined"
:source-label="sourceLabel"
:target-label="targetLabel"
show-fullscreen-button
@ -180,7 +157,7 @@ const targetLabel = computed(() =>
<template #sourceEmptyText>
<N8nText size="small" color="text-base">{{ sourceEmptyText }}</N8nText>
</template>
</WorkflowDiffView>
</component>
</div>
</template>
@ -191,7 +168,24 @@ const targetLabel = computed(() =>
}
.versionBadge {
composes: sourceBadge from '../../workflows/workflowDiff/workflowDiff.module.scss';
/* Keep the existing Reviews badge sizes when the root font changes. */
--review-version-badge--spacing: 12px;
--review-version-badge--radius: 4px;
--review-version-badge--height: 30px;
--review-version-badge--gap: 8px;
position: absolute;
top: var(--review-version-badge--spacing);
left: var(--review-version-badge--spacing);
z-index: 1;
border-radius: var(--review-version-badge--radius);
border: var(--border-width) solid var(--color--foreground--tint-1);
background: var(--color--foreground--tint-2);
display: flex;
height: var(--review-version-badge--height);
padding: 0 var(--review-version-badge--spacing);
align-items: center;
gap: var(--review-version-badge--gap);
align-self: stretch;
}
.statusDot {

View File

@ -2,8 +2,8 @@ import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { useReviewActivityStore } from '../reviewActivity.store';
import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
@ -43,6 +43,32 @@ describe('WorkflowReviewCommentComposer', () => {
expect(second.getByRole('textbox')).toHaveValue('half a thought');
});
it('clears a sent draft when a tab switch unmounts its composer', async () => {
const pending = createDeferredPromise<boolean>();
store.postComment.mockReturnValueOnce(pending.promise);
store.draft = 'Sent comment';
const first = renderComponent({ props: { canComment: true } });
await userEvent.click(first.getByTestId('send-message-button'));
first.unmount();
pending.resolve(true);
await waitFor(() => expect(store.draft).toBe(''));
const second = renderComponent({ props: { canComment: true } });
expect(second.getByRole('textbox')).toHaveValue('');
});
it('reports a failed post after a tab switch unmounts the composer', async () => {
const pending = createDeferredPromise<boolean>();
store.postComment.mockReturnValueOnce(pending.promise);
store.draft = 'Sent comment';
const { getByTestId, unmount } = renderComponent({ props: { canComment: true } });
await userEvent.click(getByTestId('send-message-button'));
unmount();
pending.reject(new Error('timeout'));
await pending.promise.catch(() => {});
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Could not post comment');
expect(store.draft).toBe('Sent comment');
});
it('disables the send button and the textarea when the viewer cannot comment', () => {
const { getByTestId, getByRole } = renderComponent({ props: { canComment: false } });

Some files were not shown because too many files have changed in this diff Show More