mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-16 21:19:43 +02:00
refactor(core): Prepare the shared Inbox (#40739)
Co-authored-by: kgrhartlage <kai.hartlage@n8n.io>
This commit is contained in:
parent
2eab299dfa
commit
8bee1eeb20
1
OWNERS
1
OWNERS
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
12
packages/@n8n/api-types/src/dto/inbox/list-inbox.dto.ts
Normal file
12
packages/@n8n/api-types/src/dto/inbox/list-inbox.dto.ts
Normal 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(),
|
||||
}) {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
45
packages/@n8n/api-types/src/inbox.ts
Normal file
45
packages/@n8n/api-types/src/inbox.ts
Normal 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[];
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export class ModuleRegistry {
|
|||
'runtime-credentials',
|
||||
'mcp-registry',
|
||||
'workflow-reviews',
|
||||
'inbox',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'agent-evals',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
354
packages/cli/src/modules/inbox/__tests__/inbox.service.test.ts
Normal file
354
packages/cli/src/modules/inbox/__tests__/inbox.service.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/cli/src/modules/inbox/inbox-source.registry.ts
Normal file
56
packages/cli/src/modules/inbox/inbox-source.registry.ts
Normal 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()];
|
||||
}
|
||||
}
|
||||
22
packages/cli/src/modules/inbox/inbox.controller.ts
Normal file
22
packages/cli/src/modules/inbox/inbox.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
packages/cli/src/modules/inbox/inbox.module.ts
Normal file
8
packages/cli/src/modules/inbox/inbox.module.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
241
packages/cli/src/modules/inbox/inbox.service.ts
Normal file
241
packages/cli/src/modules/inbox/inbox.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ const serviceGatedHandlers = new Set([
|
|||
'create',
|
||||
'updateVersion',
|
||||
'decide',
|
||||
'listInbox',
|
||||
'getSummary',
|
||||
'listActivity',
|
||||
'createComment',
|
||||
'getDetail',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)!;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ type EndpointGroup =
|
|||
| 'type-availability-policies';
|
||||
|
||||
type ModuleName =
|
||||
| 'inbox'
|
||||
| 'insights'
|
||||
| 'external-secrets'
|
||||
| 'community-packages'
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
60
packages/modules/inbox/frontend/README.md
Normal file
60
packages/modules/inbox/frontend/README.md
Normal 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.
|
||||
4
packages/modules/inbox/frontend/biome.jsonc
Normal file
4
packages/modules/inbox/frontend/biome.jsonc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../../../../biome.jsonc"]
|
||||
}
|
||||
4
packages/modules/inbox/frontend/eslint.config.mjs
Normal file
4
packages/modules/inbox/frontend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { frontendConfig } from '@n8n/eslint-config/frontend';
|
||||
|
||||
export default defineConfig(frontendConfig);
|
||||
65
packages/modules/inbox/frontend/package.json
Normal file
65
packages/modules/inbox/frontend/package.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
4
packages/modules/inbox/frontend/src/__tests__/setup.ts
Normal file
4
packages/modules/inbox/frontend/src/__tests__/setup.ts
Normal 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';
|
||||
|
|
@ -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>
|
||||
229
packages/modules/inbox/frontend/src/components/InboxList.test.ts
Normal file
229
packages/modules/inbox/frontend/src/components/InboxList.test.ts
Normal 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]]);
|
||||
});
|
||||
195
packages/modules/inbox/frontend/src/components/InboxList.vue
Normal file
195
packages/modules/inbox/frontend/src/components/InboxList.vue
Normal 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>
|
||||
134
packages/modules/inbox/frontend/src/components/InboxListItem.vue
Normal file
134
packages/modules/inbox/frontend/src/components/InboxListItem.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
)
|
||||
20
packages/modules/inbox/frontend/src/inbox.api.ts
Normal file
20
packages/modules/inbox/frontend/src/inbox.api.ts
Normal 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');
|
||||
}
|
||||
16
packages/modules/inbox/frontend/src/inbox.constants.ts
Normal file
16
packages/modules/inbox/frontend/src/inbox.constants.ts
Normal 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;
|
||||
};
|
||||
50
packages/modules/inbox/frontend/src/inbox.module.ts
Normal file
50
packages/modules/inbox/frontend/src/inbox.module.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
});
|
||||
111
packages/modules/inbox/frontend/src/inbox.routes.test.ts
Normal file
111
packages/modules/inbox/frontend/src/inbox.routes.test.ts
Normal 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();
|
||||
});
|
||||
30
packages/modules/inbox/frontend/src/inbox.routes.ts
Normal file
30
packages/modules/inbox/frontend/src/inbox.routes.ts
Normal 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 };
|
||||
}
|
||||
349
packages/modules/inbox/frontend/src/inbox.store.test.ts
Normal file
349
packages/modules/inbox/frontend/src/inbox.store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
305
packages/modules/inbox/frontend/src/inbox.store.ts
Normal file
305
packages/modules/inbox/frontend/src/inbox.store.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
5
packages/modules/inbox/frontend/src/index.ts
Normal file
5
packages/modules/inbox/frontend/src/index.ts
Normal 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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -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';
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user