mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Complete the review activity feed and add the decision note (no-changelog) (#36137)
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Util: Update Node Popularity / approve-and-automerge (push) Has been cancelled
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Util: Update Node Popularity / approve-and-automerge (push) Has been cancelled
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: kgrhartlage <kai.hartlage@n8n.io>
This commit is contained in:
parent
68b47c70f7
commit
92339bb2b0
|
|
@ -339,7 +339,7 @@ export type * from './workflow-reviews/get-workflow-review-request-detail.dto';
|
|||
export {
|
||||
ListWorkflowReviewActivityQueryDto,
|
||||
CreateWorkflowReviewCommentDto,
|
||||
WORKFLOW_REVIEW_COMMENT_MAX_LENGTH,
|
||||
WORKFLOW_REVIEW_TEXT_MAX_LENGTH,
|
||||
type ListWorkflowReviewActivityResponse,
|
||||
} from './workflow-reviews/workflow-review-activity.dto';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,30 @@ describe('DecideWorkflowReviewRequestDto', () => {
|
|||
test.each([
|
||||
{ name: 'approved decision', request: { decision: 'approved' } },
|
||||
{ name: 'changes_requested decision', request: { decision: 'changes_requested' } },
|
||||
{
|
||||
name: 'approved decision with a note',
|
||||
request: { decision: 'approved', note: 'Ships as is' },
|
||||
},
|
||||
{
|
||||
name: 'changes_requested decision with a note',
|
||||
request: { decision: 'changes_requested', note: 'Please rename the node' },
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = DecideWorkflowReviewRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toMatchObject(request);
|
||||
});
|
||||
|
||||
// Its own test because the table above asserts `toMatchObject(request)`, which a row
|
||||
// whose point is that the output differs from the input cannot satisfy.
|
||||
test('should trim the note', () => {
|
||||
const result = DecideWorkflowReviewRequestDto.safeParse({
|
||||
decision: 'approved',
|
||||
note: ' first line\nsecond line ',
|
||||
});
|
||||
|
||||
expect(result.data?.note).toBe('first line\nsecond line');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
|
|
@ -34,6 +53,21 @@ describe('DecideWorkflowReviewRequestDto', () => {
|
|||
request: { decision: 1 },
|
||||
expectedErrorPath: ['decision'],
|
||||
},
|
||||
{
|
||||
name: 'a note that is only whitespace',
|
||||
request: { decision: 'changes_requested', note: ' \n ' },
|
||||
expectedErrorPath: ['note'],
|
||||
},
|
||||
{
|
||||
name: 'a note over the length limit',
|
||||
request: { decision: 'changes_requested', note: 'x'.repeat(10_001) },
|
||||
expectedErrorPath: ['note'],
|
||||
},
|
||||
{
|
||||
name: 'a note containing a control character',
|
||||
request: { decision: 'changes_requested', note: 'oops \x00 here' },
|
||||
expectedErrorPath: ['note'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = DecideWorkflowReviewRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { reviewTextSchema } from './workflow-review-activity.dto';
|
||||
import { workflowReviewRequestDecisionSchema } from '../../workflow-review-request-summary';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class DecideWorkflowReviewRequestDto extends Z.class({
|
||||
decision: workflowReviewRequestDecisionSchema.exclude(['pending']),
|
||||
/**
|
||||
* Required for `changes_requested`, optional for `approved`. The requirement is enforced
|
||||
* service-side: `Z.class` wraps a plain `z.object` with no `superRefine` hook.
|
||||
*/
|
||||
note: reviewTextSchema.optional(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,20 @@ export interface ListWorkflowReviewActivityResponse {
|
|||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const WORKFLOW_REVIEW_COMMENT_MAX_LENGTH = 10_000;
|
||||
export const WORKFLOW_REVIEW_TEXT_MAX_LENGTH = 10_000;
|
||||
|
||||
/** Shared by the comment body and the decision note: same cap, same control-character rule. */
|
||||
export const reviewTextSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(WORKFLOW_REVIEW_TEXT_MAX_LENGTH)
|
||||
// NUL cannot be stored in a Postgres text column at all, so it would turn user input
|
||||
// into a 500. The rest of C0 is non-printing junk with no place in a comment body, and
|
||||
// is rejected in the same pass. \n, \r and \t are deliberately allowed through.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.refine((v) => !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(v), 'Contains control characters');
|
||||
|
||||
export class CreateWorkflowReviewCommentDto extends Z.class({
|
||||
body: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(WORKFLOW_REVIEW_COMMENT_MAX_LENGTH)
|
||||
// NUL cannot be stored in a Postgres text column at all, so it would turn user input
|
||||
// into a 500. The rest of C0 is non-printing junk with no place in a comment body, and
|
||||
// is rejected in the same pass. \n, \r and \t are deliberately allowed through.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.refine((v) => !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(v), 'Body contains control characters'),
|
||||
body: reviewTextSchema,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export * from './instance-registry-types';
|
|||
export * from './redaction-enforcement';
|
||||
export * from './redaction-enforcement-floor';
|
||||
export * from './workflow-reviews-policy';
|
||||
export type * from './workflow-review-activity';
|
||||
export * from './workflow-review-activity';
|
||||
export type * from './workflow-review-eligible-reviewer';
|
||||
export * from './workflow-review-request-summary';
|
||||
export * from './workflow-publish-blocked-details';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Iso8601DateTimeString } from './datetime';
|
||||
import type { WorkflowReviewEligibleReviewer } from './workflow-review-eligible-reviewer';
|
||||
|
|
@ -22,6 +22,59 @@ export type WorkflowReviewActivityType =
|
|||
* approval writes `review.approved` instead, never both. */
|
||||
| 'review.closed';
|
||||
|
||||
// These are the typeVersion 1 shapes. A typeVersion 2 gets its own schema, its own
|
||||
// union member and its own mapper case; never widen one of these in place.
|
||||
const workflowReviewClosedReasonSchema = z.enum([
|
||||
'workflow-archived',
|
||||
'workflow-moved',
|
||||
'workflow-deleted',
|
||||
]);
|
||||
export type WorkflowReviewClosedReason = z.infer<typeof workflowReviewClosedReasonSchema>;
|
||||
|
||||
/**
|
||||
* Pairs the version with its workflow: the entry is the immutable record, and the live pin it was
|
||||
* built from is prunable, so a bare version id becomes unresolvable once pruning removes the
|
||||
* history row.
|
||||
*/
|
||||
const workflowReviewActivityWorkflowVersionSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
workflowVersionId: z.string(),
|
||||
});
|
||||
export const workflowReviewOpenedActivityDataSchema = z.object({
|
||||
workflowVersions: z.array(workflowReviewActivityWorkflowVersionSchema),
|
||||
});
|
||||
export type WorkflowReviewOpenedActivityData = z.infer<
|
||||
typeof workflowReviewOpenedActivityDataSchema
|
||||
>;
|
||||
|
||||
export const workflowReviewDecisionActivityDataSchema = z.object({
|
||||
workflowVersions: z.array(workflowReviewActivityWorkflowVersionSchema),
|
||||
note: z.string().nullable(),
|
||||
});
|
||||
export type WorkflowReviewDecisionActivityData = z.infer<
|
||||
typeof workflowReviewDecisionActivityDataSchema
|
||||
>;
|
||||
|
||||
export const workflowReviewVersionUpdatedActivityDataSchema = z.object({
|
||||
/**
|
||||
* Here rather than in the entity's `workflowId` column: that column's FK cascades, so a
|
||||
* workflow delete would silently take these entries out of an append-only feed.
|
||||
*/
|
||||
workflowId: z.string(),
|
||||
fromWorkflowVersionId: z.string().nullable(),
|
||||
toWorkflowVersionId: z.string().nullable(),
|
||||
});
|
||||
export type WorkflowReviewVersionUpdatedActivityData = z.infer<
|
||||
typeof workflowReviewVersionUpdatedActivityDataSchema
|
||||
>;
|
||||
|
||||
export const workflowReviewClosedActivityDataSchema = z.object({
|
||||
reason: workflowReviewClosedReasonSchema,
|
||||
});
|
||||
export type WorkflowReviewClosedActivityData = z.infer<
|
||||
typeof workflowReviewClosedActivityDataSchema
|
||||
>;
|
||||
|
||||
type WorkflowReviewActivityBase = {
|
||||
/** Int in the database, string on the wire. */
|
||||
id: string;
|
||||
|
|
@ -43,6 +96,8 @@ export type WorkflowReviewActivityMessage = {
|
|||
|
||||
/**
|
||||
* Total over every type the CHECK constraint allows, so no stored row is ever unmappable.
|
||||
* `data: null` on a non-comment entry means the stored payload did not parse; the renderer
|
||||
* degrades rather than dropping the entry.
|
||||
*/
|
||||
export type WorkflowReviewActivityEntry =
|
||||
| (WorkflowReviewActivityBase & {
|
||||
|
|
@ -51,6 +106,22 @@ export type WorkflowReviewActivityEntry =
|
|||
messages: WorkflowReviewActivityMessage[];
|
||||
})
|
||||
| (WorkflowReviewActivityBase & {
|
||||
type: Exclude<WorkflowReviewActivityType, 'comment.created'>;
|
||||
data: IDataObject | null;
|
||||
type: 'review.opened';
|
||||
data: WorkflowReviewOpenedActivityData | null;
|
||||
})
|
||||
| (WorkflowReviewActivityBase & {
|
||||
type: 'review.changes_requested' | 'review.approved';
|
||||
data: WorkflowReviewDecisionActivityData | null;
|
||||
})
|
||||
| (WorkflowReviewActivityBase & {
|
||||
type: 'review.version_updated';
|
||||
data: WorkflowReviewVersionUpdatedActivityData | null;
|
||||
})
|
||||
| (WorkflowReviewActivityBase & {
|
||||
type: 'review.closed';
|
||||
data: WorkflowReviewClosedActivityData | null;
|
||||
})
|
||||
| (WorkflowReviewActivityBase & {
|
||||
type: 'workflow.published';
|
||||
data: null;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ export class WorkflowReviewActivity extends WithCreatedAt {
|
|||
typeVersion: number;
|
||||
|
||||
/**
|
||||
* Immutable per-type detail. Ids only: a user id stored here would survive user deletion,
|
||||
* unlike `createdById`, so a type needing another actor gets its own `SET NULL` column.
|
||||
* Immutable per-type detail: ids, plus a reviewer's note on a decision. Nothing redacts it —
|
||||
* unlike a comment body, which is nulled on delete, it is frozen once written. No user ids: one
|
||||
* stored here would survive user deletion, unlike `createdById`, so a type needing another actor
|
||||
* gets its own `SET NULL` column.
|
||||
*/
|
||||
@JsonColumn({ nullable: true })
|
||||
data: IDataObject | null;
|
||||
|
|
@ -42,9 +44,13 @@ export class WorkflowReviewActivity extends WithCreatedAt {
|
|||
createdById: string | null;
|
||||
|
||||
/**
|
||||
* Scopes an entry to one workflow; `null` for review-level entries such as comments. A column
|
||||
* rather than a key in `data` so the feed can query and filter on it directly, for instance to
|
||||
* leave out entries about workflows the reader may not open. Nothing writes it yet.
|
||||
* Scopes an entry to one workflow; `null` for review-level entries such as comments and
|
||||
* decisions. Nothing writes it yet, deliberately: the FK is `ON DELETE CASCADE`, so setting it
|
||||
* means accepting that an ordinary workflow delete removes those entries from an append-only
|
||||
* feed, which is why `review.version_updated` keeps its `workflowId` in `data`. Filtering the
|
||||
* feed on this column is the precondition for relaxing
|
||||
* `CreateWorkflowReviewRequestDto.workflows.length(1)`, since a multi-workflow review would
|
||||
* otherwise show entries about workflows the reader may not open.
|
||||
*/
|
||||
@Column({ type: 'varchar', length: 36, nullable: true })
|
||||
workflowId: string | null;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export {
|
|||
export { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
|
||||
export {
|
||||
WorkflowReviewRequestRepository,
|
||||
type ClosedUnreviewableRequest,
|
||||
type InboxCursor,
|
||||
type InboxVisibility,
|
||||
type WorkflowReviewRequestForWorkflowRow,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { WorkflowReviewActivityType } from '@n8n/api-types';
|
||||
import type {
|
||||
WorkflowReviewClosedActivityData,
|
||||
WorkflowReviewDecisionActivityData,
|
||||
WorkflowReviewOpenedActivityData,
|
||||
WorkflowReviewVersionUpdatedActivityData,
|
||||
} from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, In, LessThan } from '@n8n/typeorm';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { WorkflowReviewActivityComment } from '../entities/workflow-review-activity-comment.ee';
|
||||
|
|
@ -13,6 +17,22 @@ export type WorkflowReviewActivityFeedEntry = {
|
|||
messages: WorkflowReviewActivityComment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Write-side counterpart of `WorkflowReviewActivityEntry`, reusing its data types so the read
|
||||
* and write shapes cannot drift. Repository input only, never serialized, hence not in
|
||||
* `@n8n/api-types`. `changes_requested` and `approved` share a member on purpose, so a
|
||||
* conditional decision type still typechecks at the call site.
|
||||
*/
|
||||
export type WorkflowReviewActivityPayload =
|
||||
| { type: 'review.opened'; data: WorkflowReviewOpenedActivityData }
|
||||
| { type: 'comment.created' | 'workflow.published'; data: null }
|
||||
| {
|
||||
type: 'review.changes_requested' | 'review.approved';
|
||||
data: WorkflowReviewDecisionActivityData;
|
||||
}
|
||||
| { type: 'review.version_updated'; data: WorkflowReviewVersionUpdatedActivityData }
|
||||
| { type: 'review.closed'; data: WorkflowReviewClosedActivityData };
|
||||
|
||||
@Service()
|
||||
export class WorkflowReviewActivityRepository extends BaseRepository<WorkflowReviewActivity> {
|
||||
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
|
||||
|
|
@ -20,11 +40,14 @@ export class WorkflowReviewActivityRepository extends BaseRepository<WorkflowRev
|
|||
}
|
||||
|
||||
async createActivity(
|
||||
input: {
|
||||
input: WorkflowReviewActivityPayload & {
|
||||
workflowReviewRequestId: string;
|
||||
type: WorkflowReviewActivityType;
|
||||
data: IDataObject | null;
|
||||
createdById: string | null;
|
||||
/**
|
||||
* Scopes the entry to one workflow; omit for review-level entries. No caller sets it
|
||||
* yet — see the entity, whose FK cascades.
|
||||
*/
|
||||
workflowId?: string;
|
||||
},
|
||||
ctx: OperationContext,
|
||||
): Promise<WorkflowReviewActivity> {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import type { WorkflowReviewClosedReason } from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { WorkflowSharingRole } from '@n8n/permissions';
|
||||
import type { SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { SharedWorkflow } from '../entities/shared-workflow';
|
||||
import { WorkflowEntity } from '../entities/workflow-entity';
|
||||
import { WorkflowReviewRequestAuthor } from '../entities/workflow-review-request-author.ee';
|
||||
import { WorkflowReviewRequestReviewer } from '../entities/workflow-review-request-reviewer.ee';
|
||||
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
|
||||
|
|
@ -92,6 +95,44 @@ export type InboxStateCounts = {
|
|||
closed: number;
|
||||
};
|
||||
|
||||
/** An open request the reconciliation sweep closed, and what made its workflow unreviewable. */
|
||||
export type ClosedUnreviewableRequest = {
|
||||
id: string;
|
||||
reason: WorkflowReviewClosedReason;
|
||||
};
|
||||
|
||||
/** One row per (open request, linked workflow); a request with no link left yields one empty row. */
|
||||
type OpenRequestWorkflowRow = {
|
||||
requestId: string;
|
||||
requestProjectId: string;
|
||||
linkedWorkflowId: string | null;
|
||||
/** Raw, so the driver's own boolean: `1` on sqlite and mysql, `true` on postgres. */
|
||||
isArchived: boolean | number | null;
|
||||
owningProjectId: string | null;
|
||||
};
|
||||
|
||||
/** Most to least definitive: one workflow can be several of these at once. */
|
||||
const CLOSE_REASON_PRECEDENCE: WorkflowReviewClosedReason[] = [
|
||||
'workflow-deleted',
|
||||
'workflow-archived',
|
||||
'workflow-moved',
|
||||
];
|
||||
|
||||
function closeReasonFor(row: OpenRequestWorkflowRow): WorkflowReviewClosedReason | null {
|
||||
// Nothing behind the link: either the request has no link row left, or it points at a
|
||||
// workflow that is gone. Both mean the delete cascade got there first.
|
||||
if (row.linkedWorkflowId === null) return 'workflow-deleted';
|
||||
|
||||
if (row.isArchived) return 'workflow-archived';
|
||||
|
||||
// A workflow with no owning project at all is a broken row, not a move — leave it alone.
|
||||
if (row.owningProjectId !== null && row.owningProjectId !== row.requestProjectId) {
|
||||
return 'workflow-moved';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class WorkflowReviewRequestRepository extends BaseRepository<WorkflowReviewRequest> {
|
||||
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
|
||||
|
|
@ -139,44 +180,69 @@ export class WorkflowReviewRequestRepository extends BaseRepository<WorkflowRevi
|
|||
}
|
||||
|
||||
/**
|
||||
* Closes every open request left with no linked workflow, returning the ids closed.
|
||||
* Closes every open request whose workflow can no longer be reviewed — deleted, archived, or
|
||||
* moved out of the request's project — reporting each id with the reason that closed it.
|
||||
*
|
||||
* A workflow hard delete cascades the link rows away, so an open request that has
|
||||
* lost its last one covers nothing and can never be acted on again. `create` writes
|
||||
* the request and its link row in one transaction, so a request is only ever visible
|
||||
* without links once the workflow behind it is gone — the two steps here cannot see
|
||||
* a half-written create and so need no lock.
|
||||
* Matches on the workflow's current state rather than on the mutation that changed it, so it
|
||||
* catches what the per-mutation hooks cannot: reviews a delete cascade unlinked before a hook
|
||||
* could find them by workflow id, mutations that skip the hooks entirely, and hooks whose
|
||||
* close rolled back after their mutation had already committed.
|
||||
*
|
||||
* Keys off the ids selected rather than off the state, so the caller must hold the
|
||||
* review-request lock.
|
||||
*/
|
||||
async closeOrphanedOpenRequests(ctx: OperationContext): Promise<string[]> {
|
||||
async closeUnreviewableOpenRequests(ctx: OperationContext): Promise<ClosedUnreviewableRequest[]> {
|
||||
const openState: WorkflowReviewRequestState = 'open';
|
||||
const closedState: WorkflowReviewRequestState = 'closed';
|
||||
const ownerRole: WorkflowSharingRole = 'workflow:owner';
|
||||
const manager = this.managerFor(ctx);
|
||||
|
||||
const orphans = await manager
|
||||
const rows = await manager
|
||||
.createQueryBuilder(WorkflowReviewRequest, 'review')
|
||||
.select('review.id', 'id')
|
||||
.select('review.id', 'requestId')
|
||||
.addSelect('review.projectId', 'requestProjectId')
|
||||
.addSelect('workflow.id', 'linkedWorkflowId')
|
||||
.addSelect('workflow.isArchived', 'isArchived')
|
||||
.addSelect('shared.projectId', 'owningProjectId')
|
||||
// Left joins throughout: a request with no link, or a link with no workflow, is
|
||||
// precisely the orphan case, and dropping those rows would hide it.
|
||||
.leftJoin(WorkflowReviewRequestWorkflow, 'link', 'link.workflowReviewRequestId = review.id')
|
||||
.leftJoin(WorkflowEntity, 'workflow', 'workflow.id = link.workflowId')
|
||||
.leftJoin(
|
||||
SharedWorkflow,
|
||||
'shared',
|
||||
'shared.workflowId = link.workflowId AND shared.role = :ownerRole',
|
||||
{ ownerRole },
|
||||
)
|
||||
.where('review.state = :openState', { openState })
|
||||
.andWhere((qb) => {
|
||||
const linkedWorkflowExists = qb
|
||||
.subQuery()
|
||||
.select('1')
|
||||
.from(WorkflowReviewRequestWorkflow, 'requestWorkflow')
|
||||
.where('requestWorkflow.workflowReviewRequestId = review.id')
|
||||
.getQuery();
|
||||
return `NOT EXISTS ${linkedWorkflowExists}`;
|
||||
})
|
||||
.getRawMany<{ id: string }>();
|
||||
.getRawMany<OpenRequestWorkflowRow>();
|
||||
|
||||
if (orphans.length === 0) return [];
|
||||
const reasonByRequestId = new Map<string, WorkflowReviewClosedReason>();
|
||||
for (const row of rows) {
|
||||
const reason = closeReasonFor(row);
|
||||
if (reason === null) continue;
|
||||
|
||||
// A request linked to several workflows gets one row each, so keep the most
|
||||
// definitive reason rather than whichever row the database returned last.
|
||||
const current = reasonByRequestId.get(row.requestId);
|
||||
if (
|
||||
current !== undefined &&
|
||||
CLOSE_REASON_PRECEDENCE.indexOf(current) <= CLOSE_REASON_PRECEDENCE.indexOf(reason)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
reasonByRequestId.set(row.requestId, reason);
|
||||
}
|
||||
|
||||
if (reasonByRequestId.size === 0) return [];
|
||||
|
||||
const ids = orphans.map(({ id }) => id);
|
||||
// A system close has no closing user; the decision stays as-is.
|
||||
await manager.update(WorkflowReviewRequest, ids, {
|
||||
await manager.update(WorkflowReviewRequest, [...reasonByRequestId.keys()], {
|
||||
state: closedState,
|
||||
closedById: null,
|
||||
});
|
||||
|
||||
return ids;
|
||||
return [...reasonByRequestId].map(([id, reason]) => ({ id, reason }));
|
||||
}
|
||||
|
||||
async findById(id: string, ctx: OperationContext): Promise<WorkflowReviewRequest | null> {
|
||||
|
|
|
|||
|
|
@ -352,6 +352,244 @@ describe('Commenting on a review', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Recording the review lifecycle in the feed', () => {
|
||||
/** Two history versions so the review can be re-pinned from one to the other. */
|
||||
async function createReviewableWorkflow() {
|
||||
const workflow = await createWorkflow({ name: 'Reviewed workflow' }, teamProject);
|
||||
await createWorkflowHistoryItem(workflow.id, { versionId: 'version-1' });
|
||||
await createWorkflowHistoryItem(workflow.id, { versionId: 'version-2' });
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/** Opened by `member`, so `owner` is free to decide on it without an admin override. */
|
||||
async function openReview(workflowId: string, workflowVersionId = 'version-1') {
|
||||
const response = await memberAgent
|
||||
.post('/workflow-review-requests')
|
||||
.send({
|
||||
title: 'Please review',
|
||||
workflows: [{ workflowId, workflowVersionId, workflowVersionName: 'Release candidate' }],
|
||||
reviewerUserIds: [owner.id],
|
||||
})
|
||||
.expect(201);
|
||||
return response.body.data.id as string;
|
||||
}
|
||||
|
||||
const entryTypes = (feed: { data: FeedEntry[] }) => feed.data.map((entry) => entry.type);
|
||||
|
||||
test('shows who opened the review and which version they submitted', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
const feed = await getActivity(memberAgent, requestId);
|
||||
expect(feed.data).toHaveLength(1);
|
||||
expect(feed.data[0]).toMatchObject({
|
||||
type: 'review.opened',
|
||||
typeVersion: 1,
|
||||
createdBy: expect.objectContaining({ id: member.id }),
|
||||
});
|
||||
expect(feed.data[0].data).toEqual({
|
||||
workflowVersions: [{ workflowId: workflow.id, workflowVersionId: 'version-1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('records the note and the reviewed version when a reviewer requests changes', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await ownerAgent
|
||||
.post(`/workflow-review-requests/${requestId}/decision`)
|
||||
.send({ decision: 'changes_requested', note: 'Please rename the node' })
|
||||
.expect(200);
|
||||
|
||||
const feed = await getActivity(ownerAgent, requestId);
|
||||
expect(entryTypes(feed)).toEqual(['review.opened', 'review.changes_requested']);
|
||||
expect(feed.data[1]).toMatchObject({
|
||||
createdBy: expect.objectContaining({ id: owner.id }),
|
||||
});
|
||||
expect(feed.data[1].data).toEqual({
|
||||
workflowVersions: [{ workflowId: workflow.id, workflowVersionId: 'version-1' }],
|
||||
note: 'Please rename the node',
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses to request changes without a note, and records nothing', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await ownerAgent
|
||||
.post(`/workflow-review-requests/${requestId}/decision`)
|
||||
.send({ decision: 'changes_requested' })
|
||||
.expect(400);
|
||||
|
||||
expect(entryTypes(await getActivity(ownerAgent, requestId))).toEqual(['review.opened']);
|
||||
expect(await requestRepository.findById(requestId, {})).toMatchObject({
|
||||
state: 'open',
|
||||
decision: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
test('records the note a reviewer leaves when they approve', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await ownerAgent
|
||||
.post(`/workflow-review-requests/${requestId}/decision`)
|
||||
.send({ decision: 'approved', note: 'Ships as is' })
|
||||
.expect(200);
|
||||
|
||||
const feed = await getActivity(ownerAgent, requestId);
|
||||
expect(entryTypes(feed)).toEqual(['review.opened', 'review.approved']);
|
||||
expect(feed.data[1].data).toEqual({
|
||||
workflowVersions: [{ workflowId: workflow.id, workflowVersionId: 'version-1' }],
|
||||
note: 'Ships as is',
|
||||
});
|
||||
});
|
||||
|
||||
test('records an approval left without a note as having none', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await ownerAgent
|
||||
.post(`/workflow-review-requests/${requestId}/decision`)
|
||||
.send({ decision: 'approved' })
|
||||
.expect(200);
|
||||
|
||||
const feed = await getActivity(ownerAgent, requestId);
|
||||
// `null`, not a missing key: "no note given" and "the payload did not parse" are
|
||||
// different things on an audit record.
|
||||
expect(feed.data[1].data).toEqual({
|
||||
workflowVersions: [{ workflowId: workflow.id, workflowVersionId: 'version-1' }],
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('still names the workflow an approved version came from after that workflow is deleted', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await ownerAgent
|
||||
.post(`/workflow-review-requests/${requestId}/decision`)
|
||||
.send({ decision: 'approved' })
|
||||
.expect(200);
|
||||
|
||||
await workflowEntityRepository.delete(workflow.id);
|
||||
|
||||
// The pin cascades away with the workflow, so the entry is the only record left of which
|
||||
// version was approved.
|
||||
expect(await workflowRepository.findByRequestId(requestId, {})).toEqual([]);
|
||||
|
||||
const feed = await getActivity(ownerAgent, requestId);
|
||||
expect(feed.data[1].data).toEqual({
|
||||
workflowVersions: [{ workflowId: workflow.id, workflowVersionId: 'version-1' }],
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('records the version a re-pinned review moved from and to, scoped to that workflow', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await memberAgent
|
||||
.post(`/workflow-review-requests/${requestId}/update-version`)
|
||||
.send({
|
||||
workflowId: workflow.id,
|
||||
workflowVersionId: 'version-2',
|
||||
workflowVersionName: 'Second attempt',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const feed = await getActivity(memberAgent, requestId);
|
||||
expect(entryTypes(feed)).toEqual(['review.opened', 'review.version_updated']);
|
||||
expect(feed.data[1].data).toEqual({
|
||||
workflowId: workflow.id,
|
||||
fromWorkflowVersionId: 'version-1',
|
||||
toWorkflowVersionId: 'version-2',
|
||||
});
|
||||
|
||||
// The scoping column stays unwritten: its FK cascades, so a workflow delete would take
|
||||
// the entry with it.
|
||||
const rows = await activityRepository.findBy({ type: 'review.version_updated' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].workflowId).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps a version update in the feed after its workflow is deleted', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await memberAgent
|
||||
.post(`/workflow-review-requests/${requestId}/update-version`)
|
||||
.send({
|
||||
workflowId: workflow.id,
|
||||
workflowVersionId: 'version-2',
|
||||
workflowVersionName: 'Second attempt',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// Straight from the repository, as a folder-hierarchy cascade does, so nothing but the
|
||||
// database's own cascades decides what survives.
|
||||
await workflowEntityRepository.delete(workflow.id);
|
||||
|
||||
const feed = await getActivity(memberAgent, requestId);
|
||||
expect(entryTypes(feed)).toEqual(['review.opened', 'review.version_updated']);
|
||||
expect(feed.data[1].data).toMatchObject({ workflowId: workflow.id });
|
||||
});
|
||||
|
||||
test('records nothing when a review is re-pinned to the version it already covers', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
|
||||
await memberAgent
|
||||
.post(`/workflow-review-requests/${requestId}/update-version`)
|
||||
.send({
|
||||
workflowId: workflow.id,
|
||||
workflowVersionId: 'version-1',
|
||||
workflowVersionName: 'Renamed only',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(entryTypes(await getActivity(memberAgent, requestId))).toEqual(['review.opened']);
|
||||
});
|
||||
|
||||
test('leaves the review undecided when its feed entry cannot be written', async () => {
|
||||
const workflow = await createReviewableWorkflow();
|
||||
const requestId = await openReview(workflow.id);
|
||||
vi.spyOn(activityRepository, 'createActivity').mockRejectedValueOnce(new Error('write failed'));
|
||||
|
||||
await ownerAgent
|
||||
.post(`/workflow-review-requests/${requestId}/decision`)
|
||||
.send({ decision: 'approved' })
|
||||
.expect(500);
|
||||
|
||||
expect(await requestRepository.findById(requestId, {})).toMatchObject({
|
||||
state: 'open',
|
||||
decision: 'pending',
|
||||
});
|
||||
expect(entryTypes(await getActivity(ownerAgent, requestId))).toEqual(['review.opened']);
|
||||
});
|
||||
|
||||
// Only a stored row can be malformed, so these are seeded past the write union.
|
||||
test.each([
|
||||
['a decision whose payload is not an object', 'review.changes_requested', 'oops'],
|
||||
['a close with a reason this version does not know', 'review.closed', { reason: 'nope' }],
|
||||
['a version update missing its version ids', 'review.version_updated', { from: 1 }],
|
||||
['an opening entry written in an older shape', 'review.opened', { index: 1 }],
|
||||
])('serves %s without its details rather than failing the feed', async (_label, type, data) => {
|
||||
const { request } = await seedReviewInTeamProject(owner);
|
||||
await activityRepository.createActivity(
|
||||
{ workflowReviewRequestId: request.id, type, data, createdById: owner.id } as never,
|
||||
{},
|
||||
);
|
||||
|
||||
const feed = await getActivity(ownerAgent, request.id);
|
||||
|
||||
expect(feed.data).toHaveLength(1);
|
||||
expect(feed.data[0].type).toBe(type);
|
||||
expect(feed.data[0].data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reading the activity feed', () => {
|
||||
/** Non-comment entries, cheap to seed and enough to pin the paging arithmetic. */
|
||||
async function seedEntries(workflowReviewRequestId: string, count: number) {
|
||||
|
|
@ -361,7 +599,7 @@ describe('Reading the activity feed', () => {
|
|||
{
|
||||
workflowReviewRequestId,
|
||||
type: 'review.opened',
|
||||
data: { index },
|
||||
data: { workflowVersions: [] },
|
||||
createdById: owner.id,
|
||||
},
|
||||
{},
|
||||
|
|
@ -502,7 +740,10 @@ describe('Reading the activity feed', () => {
|
|||
|
||||
test('shows a non-comment activity entry with its details intact and no messages', async () => {
|
||||
const { request } = await seedReviewInTeamProject(owner);
|
||||
const data = { workflowVersionIds: ['version-pinned'], note: 'needs work' };
|
||||
const data = {
|
||||
workflowVersions: [{ workflowId: 'workflow-reviewed', workflowVersionId: 'version-pinned' }],
|
||||
note: 'needs work',
|
||||
};
|
||||
await activityRepository.createActivity(
|
||||
{
|
||||
workflowReviewRequestId: request.id,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
SharedWorkflowRepository,
|
||||
UserRepository,
|
||||
WorkflowRepository,
|
||||
WorkflowReviewActivityRepository,
|
||||
WorkflowReviewRequestAuthorRepository,
|
||||
WorkflowReviewRequestRepository,
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
|
|
@ -62,12 +63,14 @@ let ownerAgent: SuperAgentTest;
|
|||
let requestRepository: WorkflowReviewRequestRepository;
|
||||
let linkRepository: WorkflowReviewRequestWorkflowRepository;
|
||||
let authorRepository: WorkflowReviewRequestAuthorRepository;
|
||||
let activityRepository: WorkflowReviewActivityRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.initNodeTypes();
|
||||
requestRepository = Container.get(WorkflowReviewRequestRepository);
|
||||
linkRepository = Container.get(WorkflowReviewRequestWorkflowRepository);
|
||||
authorRepository = Container.get(WorkflowReviewRequestAuthorRepository);
|
||||
activityRepository = Container.get(WorkflowReviewActivityRepository);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -75,6 +78,8 @@ beforeEach(async () => {
|
|||
testServer.license.enable('feat:workflowReviews');
|
||||
|
||||
await testDb.truncate([
|
||||
'WorkflowReviewActivityComment',
|
||||
'WorkflowReviewActivity',
|
||||
'WorkflowReviewRequestAuthor',
|
||||
'WorkflowReviewRequestReviewer',
|
||||
'WorkflowReviewRequestWorkflow',
|
||||
|
|
@ -98,10 +103,18 @@ beforeEach(async () => {
|
|||
ownerAgent = testServer.authAgentFor(owner);
|
||||
});
|
||||
|
||||
/** Read through the endpoint, so the entries are asserted exactly as a reader sees them. */
|
||||
async function getActivityEntries(requestId: string) {
|
||||
const response = await ownerAgent
|
||||
.get(`/workflow-review-requests/${requestId}/activity`)
|
||||
.expect(200);
|
||||
return response.body.data.data as Array<{ type: string; data: { reason?: string } | null }>;
|
||||
}
|
||||
|
||||
/** Create a workflow owned by `owner` with a pinned history version. */
|
||||
async function createReviewableWorkflow() {
|
||||
async function createReviewableWorkflow(attributes: { isArchived?: boolean } = {}) {
|
||||
const versionId = uuid();
|
||||
const workflow = await createWorkflow({ versionId }, owner);
|
||||
const workflow = await createWorkflow({ versionId, ...attributes }, owner);
|
||||
await createWorkflowHistoryItem(workflow.id, { versionId });
|
||||
return { workflow, versionId };
|
||||
}
|
||||
|
|
@ -146,6 +159,54 @@ describe('auto-close on workflow archive', () => {
|
|||
expect(closed?.decision).toBe('changes_requested');
|
||||
expect(closed?.closedById).toBeNull();
|
||||
expect(closed?.approvedAt).toBeNull();
|
||||
|
||||
expect(await getActivityEntries(request.id)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'review.closed',
|
||||
data: { reason: 'workflow-archived' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
// The close and its explanation share one transaction, so an unwritable entry rolls the
|
||||
// close back — and archiving still succeeds, because cleanup never fails the mutation. The
|
||||
// reconciliation sweep that follows the hook picks the rolled-back review straight back up.
|
||||
test('archives the workflow anyway when the review cannot be explained, and the sweep closes it', async () => {
|
||||
const { workflow, versionId } = await createReviewableWorkflow();
|
||||
const request = await createOpenReview(workflow.id, versionId);
|
||||
vi.spyOn(activityRepository, 'createActivity').mockRejectedValueOnce(new Error('write failed'));
|
||||
|
||||
await ownerAgent.post(`/workflows/${workflow.id}/archive`).expect(200);
|
||||
|
||||
expect((await requestRepository.findById(request.id, {}))?.state).toBe('closed');
|
||||
expect(await getActivityEntries(request.id)).toEqual([
|
||||
expect.objectContaining({ type: 'review.closed', data: { reason: 'workflow-archived' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
// Both the targeted close and the sweep that follows it are down, so the review is stranded
|
||||
// open on a workflow that is already archived — the state the next sweep has to repair.
|
||||
test('a review stranded open on an archived workflow is closed by the next sweep', async () => {
|
||||
const { workflow, versionId } = await createReviewableWorkflow();
|
||||
const request = await createOpenReview(workflow.id, versionId);
|
||||
vi.spyOn(activityRepository, 'createActivity')
|
||||
.mockRejectedValueOnce(new Error('write failed'))
|
||||
.mockRejectedValueOnce(new Error('write failed'));
|
||||
|
||||
await ownerAgent.post(`/workflows/${workflow.id}/archive`).expect(200);
|
||||
expect((await requestRepository.findById(request.id, {}))?.state).toBe('open');
|
||||
|
||||
// Any later lifecycle mutation runs the sweep again — this one touches an unrelated
|
||||
// workflow, so only the sweep can reach the stranded review.
|
||||
const unrelated = await createReviewableWorkflow();
|
||||
await ownerAgent.post(`/workflows/${unrelated.workflow.id}/archive`).expect(200);
|
||||
|
||||
const closed = await requestRepository.findById(request.id, {});
|
||||
expect(closed?.state).toBe('closed');
|
||||
expect(closed?.closedById).toBeNull();
|
||||
expect(await getActivityEntries(request.id)).toEqual([
|
||||
expect.objectContaining({ type: 'review.closed', data: { reason: 'workflow-archived' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('unarchiving does not reopen the review, and the workflow is no longer publish-blocked', async () => {
|
||||
|
|
@ -192,6 +253,48 @@ describe('auto-close on workflow transfer', () => {
|
|||
expect(closed?.state).toBe('closed');
|
||||
expect(closed?.decision).toBe('pending');
|
||||
expect(await requestRepository.findOpenRequestForWorkflow(workflow.id, {})).toBeNull();
|
||||
|
||||
expect(await getActivityEntries(request.id)).toEqual([
|
||||
expect.objectContaining({ type: 'review.closed', data: { reason: 'workflow-moved' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
// The move stays committed when its close rolls back, leaving the review open on a workflow
|
||||
// that now belongs to another project — the sweep still has the link row to find it by.
|
||||
test('a review stranded open on a moved workflow is closed by the next sweep', async () => {
|
||||
const { workflow, versionId } = await createReviewableWorkflow();
|
||||
const request = await createOpenReview(workflow.id, versionId);
|
||||
const destination = await createTeamProject('Destination', owner);
|
||||
vi.spyOn(activityRepository, 'createActivity')
|
||||
.mockRejectedValueOnce(new Error('write failed'))
|
||||
.mockRejectedValueOnce(new Error('write failed'));
|
||||
|
||||
await Container.get(EnterpriseWorkflowService).transferWorkflow(
|
||||
owner,
|
||||
workflow.id,
|
||||
destination.id,
|
||||
);
|
||||
expect((await requestRepository.findById(request.id, {}))?.state).toBe('open');
|
||||
|
||||
const unrelated = await createReviewableWorkflow();
|
||||
await ownerAgent.post(`/workflows/${unrelated.workflow.id}/archive`).expect(200);
|
||||
|
||||
const closed = await requestRepository.findById(request.id, {});
|
||||
expect(closed?.state).toBe('closed');
|
||||
expect(await linkRepository.findByRequestId(request.id, {})).toHaveLength(1);
|
||||
expect(await getActivityEntries(request.id)).toEqual([
|
||||
expect.objectContaining({ type: 'review.closed', data: { reason: 'workflow-moved' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('a review whose workflow is still in its project and unarchived is left open', async () => {
|
||||
const { workflow, versionId } = await createReviewableWorkflow();
|
||||
const request = await createOpenReview(workflow.id, versionId);
|
||||
|
||||
const unrelated = await createReviewableWorkflow();
|
||||
await ownerAgent.post(`/workflows/${unrelated.workflow.id}/archive`).expect(200);
|
||||
|
||||
expect((await requestRepository.findById(request.id, {}))?.state).toBe('open');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -206,6 +309,26 @@ describe('auto-close on workflow hard delete', () => {
|
|||
expect(closed?.state).toBe('closed');
|
||||
// The link row cascaded away with the workflow; the request itself remains, closed.
|
||||
expect(await linkRepository.findByRequestId(request.id, {})).toHaveLength(0);
|
||||
|
||||
// The pre-delete hook and the post-delete sweep both run here, so the count matters
|
||||
// as much as the reason: the review must be explained exactly once.
|
||||
expect(await getActivityEntries(request.id)).toEqual([
|
||||
expect.objectContaining({ type: 'review.closed', data: { reason: 'workflow-deleted' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
// The pre-delete hook shares one transaction with its explanation and rethrows, so an
|
||||
// unwritable entry calls the delete off rather than leaving a closed review unexplained.
|
||||
test('refuses to delete the workflow when the review cannot be explained', async () => {
|
||||
const { workflow, versionId } = await createReviewableWorkflow({ isArchived: true });
|
||||
const request = await createOpenReview(workflow.id, versionId);
|
||||
vi.spyOn(activityRepository, 'createActivity').mockRejectedValueOnce(new Error('write failed'));
|
||||
|
||||
await ownerAgent.delete(`/workflows/${workflow.id}`).expect(500);
|
||||
|
||||
expect(await Container.get(WorkflowRepository).findOneBy({ id: workflow.id })).not.toBeNull();
|
||||
expect((await requestRepository.findById(request.id, {}))?.state).toBe('open');
|
||||
expect(await getActivityEntries(request.id)).toEqual([]);
|
||||
});
|
||||
|
||||
// A review opened after the pre-delete hook ran loses its link row to the cascade and
|
||||
|
|
@ -220,12 +343,18 @@ describe('auto-close on workflow hard delete', () => {
|
|||
expect((await requestRepository.findById(request.id, {}))?.state).toBe('open');
|
||||
expect(await linkRepository.findByRequestId(request.id, {})).toHaveLength(0);
|
||||
|
||||
// `unrelated` has no review of its own, so the pre-delete hook finds nothing to close
|
||||
// and the sweep is the only thing that can explain the orphaned review.
|
||||
const unrelated = await createReviewableWorkflow();
|
||||
await Container.get(WorkflowService).delete(owner, unrelated.workflow.id, true);
|
||||
|
||||
const closed = await requestRepository.findById(request.id, {});
|
||||
expect(closed?.state).toBe('closed');
|
||||
expect(closed?.closedById).toBeNull();
|
||||
|
||||
expect(await getActivityEntries(request.id)).toEqual([
|
||||
expect.objectContaining({ type: 'review.closed', data: { reason: 'workflow-deleted' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaves a review whose workflow still exists open', async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import { DbLock } from '@n8n/db';
|
||||
import type {
|
||||
DbLockService,
|
||||
OperationContext,
|
||||
Transaction,
|
||||
WorkflowReviewActivity,
|
||||
WorkflowReviewActivityRepository,
|
||||
WorkflowReviewRequest,
|
||||
WorkflowReviewRequestRepository,
|
||||
} from '@n8n/db';
|
||||
|
|
@ -15,6 +18,7 @@ import { WorkflowReviewAutoCloseService } from '../workflow-review-auto-close.se
|
|||
describe('WorkflowReviewAutoCloseService', () => {
|
||||
const logger = mock<Logger>();
|
||||
const requestRepository = mock<WorkflowReviewRequestRepository>();
|
||||
const activityRepository = mock<WorkflowReviewActivityRepository>();
|
||||
const dbLockService = mock<DbLockService>();
|
||||
const collaborationService = mock<CollaborationService>();
|
||||
/** The lock's context. Distinct from the root `{}` so tests can tell the two apart. */
|
||||
|
|
@ -23,6 +27,7 @@ describe('WorkflowReviewAutoCloseService', () => {
|
|||
const service = new WorkflowReviewAutoCloseService(
|
||||
logger,
|
||||
requestRepository,
|
||||
activityRepository,
|
||||
dbLockService,
|
||||
collaborationService,
|
||||
);
|
||||
|
|
@ -40,6 +45,7 @@ describe('WorkflowReviewAutoCloseService', () => {
|
|||
vi.resetAllMocks();
|
||||
dbLockService.withLockContext.mockImplementation(async (_id, fn) => await fn(ctx));
|
||||
requestRepository.saveRequest.mockImplementation(async (request) => request);
|
||||
requestRepository.closeUnreviewableOpenRequests.mockResolvedValue([]);
|
||||
collaborationService.broadcastWorkflowReviewStateChanged.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
|
|
@ -132,38 +138,95 @@ describe('WorkflowReviewAutoCloseService', () => {
|
|||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('afterWorkflowsDeleted', () => {
|
||||
it('closes the requests the delete orphaned, outside any lock', async () => {
|
||||
requestRepository.closeOrphanedOpenRequests.mockResolvedValue(['req-9']);
|
||||
describe('reconciliation sweep', () => {
|
||||
it('closes the requests the delete orphaned and explains each of them', async () => {
|
||||
requestRepository.closeUnreviewableOpenRequests.mockResolvedValue([
|
||||
{ id: 'req-9', reason: 'workflow-deleted' },
|
||||
]);
|
||||
activityRepository.createActivity.mockResolvedValue(mock<WorkflowReviewActivity>());
|
||||
|
||||
await service.afterWorkflowsDeleted(['wf-1', 'wf-2']);
|
||||
|
||||
// A single atomic statement pair, so it needs no lock — and must not take one
|
||||
// after a delete, where camping on the create lock would serialize submissions.
|
||||
// The sweep is global, so the batch never reaches the query; it is log context only.
|
||||
expect(requestRepository.closeOrphanedOpenRequests).toHaveBeenCalledExactlyOnceWith({});
|
||||
expect(dbLockService.withLockContext).not.toHaveBeenCalled();
|
||||
// Same lock and same transaction as every other close path: the sweep updates the
|
||||
// requests it selected by id, so two racing sweeps would otherwise explain the same
|
||||
// close twice. The sweep is global, so the batch never reaches the query; it is log
|
||||
// context only.
|
||||
expect(dbLockService.withLockContext).toHaveBeenCalledWith(
|
||||
DbLock.WORKFLOW_REVIEW_REQUEST_CREATE,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(requestRepository.closeUnreviewableOpenRequests).toHaveBeenCalledExactlyOnceWith(ctx);
|
||||
expect(activityRepository.createActivity).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflowReviewRequestId: 'req-9',
|
||||
type: 'review.closed',
|
||||
data: { reason: 'workflow-deleted' },
|
||||
createdById: null,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
reason: 'workflow-deleted',
|
||||
workflowIds: ['wf-1', 'wf-2'],
|
||||
workflowReviewRequestIds: ['req-9'],
|
||||
closedRequests: [{ id: 'req-9', reason: 'workflow-deleted' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet when the delete orphaned nothing', async () => {
|
||||
requestRepository.closeOrphanedOpenRequests.mockResolvedValue([]);
|
||||
// The reason is per request, not per mutation: one sweep can find a review left by an
|
||||
// archive next to one left by a move.
|
||||
it('explains each request with the reason the sweep reported for it', async () => {
|
||||
requestRepository.closeUnreviewableOpenRequests.mockResolvedValue([
|
||||
{ id: 'req-1', reason: 'workflow-archived' },
|
||||
{ id: 'req-2', reason: 'workflow-moved' },
|
||||
]);
|
||||
activityRepository.createActivity.mockResolvedValue(mock<WorkflowReviewActivity>());
|
||||
|
||||
await service.afterWorkflowsDeleted(['wf-1']);
|
||||
|
||||
expect(activityRepository.createActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowReviewRequestId: 'req-1',
|
||||
data: { reason: 'workflow-archived' },
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
expect(activityRepository.createActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowReviewRequestId: 'req-2',
|
||||
data: { reason: 'workflow-moved' },
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet when nothing is left unreviewable', async () => {
|
||||
requestRepository.closeUnreviewableOpenRequests.mockResolvedValue([]);
|
||||
|
||||
await service.afterWorkflowsDeleted(['wf-1']);
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The close and its explanation share one transaction, so an unwritable entry rolls the
|
||||
// close back and the next sweep picks the review up again. The delete already committed,
|
||||
// so there is nothing left to fail.
|
||||
it('leaves a review it cannot explain to the next sweep', async () => {
|
||||
requestRepository.closeUnreviewableOpenRequests.mockResolvedValue([
|
||||
{ id: 'req-9', reason: 'workflow-deleted' },
|
||||
]);
|
||||
activityRepository.createActivity.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.afterWorkflowsDeleted(['wf-1'])).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The delete already committed, so there is nothing left to abort.
|
||||
it('swallows repository errors, unlike the pre-delete hook', async () => {
|
||||
requestRepository.closeOrphanedOpenRequests.mockRejectedValue(new Error('db down'));
|
||||
requestRepository.closeUnreviewableOpenRequests.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.afterWorkflowsDeleted(['wf-1', 'wf-2'])).resolves.toBeUndefined();
|
||||
|
||||
|
|
@ -172,6 +235,37 @@ describe('WorkflowReviewAutoCloseService', () => {
|
|||
expect.objectContaining({ workflowIds: ['wf-1', 'wf-2'] }),
|
||||
);
|
||||
});
|
||||
|
||||
// An archive or a move whose close rolled back stays committed, so the sweep has to run
|
||||
// there too — the targeted close is done with the review by then.
|
||||
it('runs after the targeted close on archive, and again on transfer', async () => {
|
||||
requestRepository.findOpenRequestsForWorkflows.mockResolvedValue([]);
|
||||
|
||||
await service.afterWorkflowArchived('wf-1');
|
||||
expect(requestRepository.closeUnreviewableOpenRequests).toHaveBeenCalledExactlyOnceWith(ctx);
|
||||
|
||||
await service.afterWorkflowsTransferred(['wf-2']);
|
||||
expect(requestRepository.closeUnreviewableOpenRequests).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A close that rolled back is exactly what the sweep is there to repair, so a throwing
|
||||
// targeted close must not skip it.
|
||||
it('still runs when the targeted close on archive failed', async () => {
|
||||
requestRepository.findOpenRequestsForWorkflows.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.afterWorkflowArchived('wf-1')).resolves.toBeUndefined();
|
||||
|
||||
expect(requestRepository.closeUnreviewableOpenRequests).toHaveBeenCalledExactlyOnceWith(ctx);
|
||||
});
|
||||
|
||||
// The pre-delete hook aborts the delete instead, so there is nothing to reconcile.
|
||||
it('does not run before a delete', async () => {
|
||||
requestRepository.findOpenRequestsForWorkflows.mockResolvedValue([]);
|
||||
|
||||
await service.beforeWorkflowDeleted('wf-1');
|
||||
|
||||
expect(requestRepository.closeUnreviewableOpenRequests).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('a failed broadcast is only warned about, never thrown', async () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { DecideWorkflowReviewRequestDto } from '@n8n/api-types';
|
|||
import type { LicenseState, Logger } from '@n8n/backend-common';
|
||||
import type {
|
||||
DbLockService,
|
||||
Project,
|
||||
ProjectRelationRepository,
|
||||
SharedWorkflowRepository,
|
||||
User,
|
||||
|
|
@ -12,6 +13,7 @@ import type {
|
|||
WorkflowReviewRequest,
|
||||
WorkflowReviewRequestAuthorRepository,
|
||||
WorkflowReviewRequestRepository,
|
||||
WorkflowReviewActivityRepository,
|
||||
WorkflowReviewRequestReviewerRepository,
|
||||
WorkflowReviewRequestWorkflow,
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
|
|
@ -23,6 +25,7 @@ import { DbLock } from '@n8n/db';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CollaborationService } from '@/collaboration/collaboration.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
|
@ -46,19 +49,24 @@ const requesterUser = mock<User>({
|
|||
const requestId = 'req-1';
|
||||
const projectId = 'proj-1';
|
||||
const approveDto: DecideWorkflowReviewRequestDto = { decision: 'approved' };
|
||||
const requestChangesDto: DecideWorkflowReviewRequestDto = { decision: 'changes_requested' };
|
||||
const requestChangesDto: DecideWorkflowReviewRequestDto = {
|
||||
decision: 'changes_requested',
|
||||
note: 'Please rename the node',
|
||||
};
|
||||
|
||||
describe('WorkflowReviewRequestService.decide', () => {
|
||||
const workflowReviewPolicyService = mock<WorkflowReviewPolicyService>();
|
||||
const workflowFinderService = mock<WorkflowFinderService>();
|
||||
const workflowHistoryService = mock<WorkflowHistoryService>();
|
||||
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
|
||||
const workflowEntityRepository = mock<WorkflowRepository>();
|
||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||
const publishHistoryRepository = mock<WorkflowPublishHistoryRepository>();
|
||||
const requestRepository = mock<WorkflowReviewRequestRepository>();
|
||||
const workflowRepository = mock<WorkflowReviewRequestWorkflowRepository>();
|
||||
const authorRepository = mock<WorkflowReviewRequestAuthorRepository>();
|
||||
const reviewerRepository = mock<WorkflowReviewRequestReviewerRepository>();
|
||||
const activityRepository = mock<WorkflowReviewActivityRepository>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const projectRelationRepository = mock<ProjectRelationRepository>();
|
||||
const roleService = mock<RoleService>();
|
||||
|
|
@ -76,13 +84,14 @@ describe('WorkflowReviewRequestService.decide', () => {
|
|||
workflowFinderService,
|
||||
workflowHistoryService,
|
||||
workflowHistoryRepository,
|
||||
mock<WorkflowRepository>(),
|
||||
workflowEntityRepository,
|
||||
sharedWorkflowRepository,
|
||||
publishHistoryRepository,
|
||||
requestRepository,
|
||||
workflowRepository,
|
||||
authorRepository,
|
||||
reviewerRepository,
|
||||
activityRepository,
|
||||
userRepository,
|
||||
// Real service over the same mocks, so the override assertions below
|
||||
// exercise the actual eligibility logic decide() shares with the read side.
|
||||
|
|
@ -125,6 +134,10 @@ describe('WorkflowReviewRequestService.decide', () => {
|
|||
workflowFinderService.findWorkflowForUser.mockResolvedValue(
|
||||
mock<WorkflowEntity>({ isArchived: false }),
|
||||
);
|
||||
workflowEntityRepository.findArchivedState.mockResolvedValue({ isArchived: false });
|
||||
sharedWorkflowRepository.getWorkflowOwningProject.mockResolvedValue(
|
||||
mock<Project>({ id: projectId }),
|
||||
);
|
||||
authorRepository.isAuthor.mockResolvedValue(false);
|
||||
reviewerRepository.isReviewer.mockResolvedValue(true);
|
||||
projectRelationRepository.getAccessibleProjectsByRoles.mockResolvedValue([]);
|
||||
|
|
@ -231,6 +244,18 @@ describe('WorkflowReviewRequestService.decide', () => {
|
|||
expect(dbLockService.withLockContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The missing note is a payload problem, and an author is not entitled to hear about it:
|
||||
// they may not decide at all, whatever they sent.
|
||||
it('tells an author they may not decide even when their note is missing too', async () => {
|
||||
mockSuccessfulDecidePath();
|
||||
authorRepository.isAuthor.mockResolvedValue(true);
|
||||
projectRelationRepository.getAccessibleProjectsByRoles.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.decide(memberUser(), requestId, { decision: 'changes_requested' }),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('rejects a caller who became an author while waiting for the lock', async () => {
|
||||
mockSuccessfulDecidePath();
|
||||
// Not an author before the lock, but a version sync won the lock first and
|
||||
|
|
@ -392,6 +417,27 @@ describe('WorkflowReviewRequestService.decide', () => {
|
|||
expect(collaborationService.broadcastWorkflowReviewStateChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to approve a workflow archived while the decision waited for the lock', async () => {
|
||||
mockSuccessfulDecidePath();
|
||||
// The pre-lock lookups still see a live workflow; only the in-lock re-read
|
||||
// sees the archive that committed while this decision queued.
|
||||
workflowEntityRepository.findArchivedState.mockResolvedValue({ isArchived: true });
|
||||
|
||||
const decision = service.decide(memberUser(), requestId, approveDto);
|
||||
await expect(decision).rejects.toThrow(BadRequestError);
|
||||
// The reviewer is told their review was refused, not that the workflow
|
||||
// "cannot be submitted for review" — that is the author's action, not theirs.
|
||||
await expect(decision).rejects.toThrow(
|
||||
"The workflow 'wf-1' is archived and cannot be reviewed",
|
||||
);
|
||||
|
||||
// Nothing may reach the activity feed: an approval entry here would durably
|
||||
// assert a decision on a workflow that had already left the reviewable state.
|
||||
expect(activityRepository.createActivity).not.toHaveBeenCalled();
|
||||
expect(requestRepository.saveRequest).not.toHaveBeenCalled();
|
||||
expect(workflowService.activateWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports and publishes the version re-pinned by a concurrent sync that won the lock', async () => {
|
||||
mockSuccessfulDecidePath();
|
||||
workflowRepository.findByRequestId
|
||||
|
|
@ -549,6 +595,15 @@ describe('WorkflowReviewRequestService.decide', () => {
|
|||
status: 'failed',
|
||||
message: 'The reviewed workflow version no longer exists',
|
||||
});
|
||||
// A `[null]` here is rejected on read, which would take the reviewer's note down
|
||||
// with it and leave an approval nobody can account for.
|
||||
expect(activityRepository.createActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'review.approved',
|
||||
data: { workflowVersions: [], note: null },
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
WorkflowReviewRequestAuthorRepository,
|
||||
WorkflowReviewRequestRepository,
|
||||
WorkflowReviewRequestForWorkflowRow,
|
||||
WorkflowReviewActivityRepository,
|
||||
WorkflowReviewRequestReviewerRepository,
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
WorkflowRepository,
|
||||
|
|
@ -71,6 +72,7 @@ describe('WorkflowReviewRequestService', () => {
|
|||
const workflowRepository = mock<WorkflowReviewRequestWorkflowRepository>();
|
||||
const authorRepository = mock<WorkflowReviewRequestAuthorRepository>();
|
||||
const reviewerRepository = mock<WorkflowReviewRequestReviewerRepository>();
|
||||
const activityRepository = mock<WorkflowReviewActivityRepository>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const eligibilityService = mock<WorkflowReviewEligibilityService>();
|
||||
const roleService = mock<RoleService>();
|
||||
|
|
@ -95,6 +97,7 @@ describe('WorkflowReviewRequestService', () => {
|
|||
workflowRepository,
|
||||
authorRepository,
|
||||
reviewerRepository,
|
||||
activityRepository,
|
||||
userRepository,
|
||||
eligibilityService,
|
||||
roleService,
|
||||
|
|
@ -264,7 +267,11 @@ describe('WorkflowReviewRequestService', () => {
|
|||
mockSuccessfulCreatePath();
|
||||
workflowEntityRepository.findArchivedState.mockResolvedValue({ isArchived: true });
|
||||
|
||||
await expect(service.create(user, dto)).rejects.toThrow(BadRequestError);
|
||||
const creation = service.create(user, dto);
|
||||
await expect(creation).rejects.toThrow(BadRequestError);
|
||||
await expect(creation).rejects.toThrow(
|
||||
"The workflow 'wf-1' is archived and cannot be submitted for review",
|
||||
);
|
||||
|
||||
expect(dbLockService.withLockContext).toHaveBeenCalled();
|
||||
expect(requestRepository.createRequest).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { LicenseState, Logger } from '@n8n/backend-common';
|
|||
import { DbLock } from '@n8n/db';
|
||||
import type {
|
||||
DbLockService,
|
||||
Project,
|
||||
SharedWorkflowRepository,
|
||||
User,
|
||||
UserRepository,
|
||||
|
|
@ -13,6 +14,7 @@ import type {
|
|||
WorkflowReviewRequest,
|
||||
WorkflowReviewRequestAuthorRepository,
|
||||
WorkflowReviewRequestRepository,
|
||||
WorkflowReviewActivityRepository,
|
||||
WorkflowReviewRequestReviewerRepository,
|
||||
WorkflowReviewRequestWorkflow,
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
|
|
@ -40,6 +42,7 @@ import { WorkflowReviewRequestService } from '../workflow-review-request.service
|
|||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
const requestId = 'req-1';
|
||||
const projectId = 'proj-1';
|
||||
const dto: UpdateWorkflowReviewRequestVersionDto = {
|
||||
workflowId: 'wf-1',
|
||||
workflowVersionId: 'ver-2',
|
||||
|
|
@ -51,12 +54,14 @@ describe('WorkflowReviewRequestService.updateVersion', () => {
|
|||
const workflowFinderService = mock<WorkflowFinderService>();
|
||||
const workflowHistoryService = mock<WorkflowHistoryService>();
|
||||
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
|
||||
const workflowEntityRepository = mock<WorkflowRepository>();
|
||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||
const publishHistoryRepository = mock<WorkflowPublishHistoryRepository>();
|
||||
const requestRepository = mock<WorkflowReviewRequestRepository>();
|
||||
const workflowRepository = mock<WorkflowReviewRequestWorkflowRepository>();
|
||||
const authorRepository = mock<WorkflowReviewRequestAuthorRepository>();
|
||||
const reviewerRepository = mock<WorkflowReviewRequestReviewerRepository>();
|
||||
const activityRepository = mock<WorkflowReviewActivityRepository>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const eligibilityService = mock<WorkflowReviewEligibilityService>();
|
||||
const roleService = mock<RoleService>();
|
||||
|
|
@ -74,13 +79,14 @@ describe('WorkflowReviewRequestService.updateVersion', () => {
|
|||
workflowFinderService,
|
||||
workflowHistoryService,
|
||||
workflowHistoryRepository,
|
||||
mock<WorkflowRepository>(),
|
||||
workflowEntityRepository,
|
||||
sharedWorkflowRepository,
|
||||
publishHistoryRepository,
|
||||
requestRepository,
|
||||
workflowRepository,
|
||||
authorRepository,
|
||||
reviewerRepository,
|
||||
activityRepository,
|
||||
userRepository,
|
||||
eligibilityService,
|
||||
roleService,
|
||||
|
|
@ -92,6 +98,7 @@ describe('WorkflowReviewRequestService.updateVersion', () => {
|
|||
const openRequest = (overrides: Partial<WorkflowReviewRequest> = {}) =>
|
||||
mock<WorkflowReviewRequest>({
|
||||
id: requestId,
|
||||
projectId,
|
||||
state: 'open',
|
||||
decision: 'pending',
|
||||
description: null,
|
||||
|
|
@ -112,6 +119,10 @@ describe('WorkflowReviewRequestService.updateVersion', () => {
|
|||
workflowFinderService.findWorkflowForUser.mockResolvedValue(
|
||||
mock<WorkflowEntity>({ isArchived: false }),
|
||||
);
|
||||
workflowEntityRepository.findArchivedState.mockResolvedValue({ isArchived: false });
|
||||
sharedWorkflowRepository.getWorkflowOwningProject.mockResolvedValue(
|
||||
mock<Project>({ id: projectId }),
|
||||
);
|
||||
workflowHistoryService.findVersion.mockResolvedValue(mock());
|
||||
workflowHistoryRepository.updateVersionMetadata.mockResolvedValue(1);
|
||||
requestRepository.saveRequest.mockImplementation(async (request) => request);
|
||||
|
|
@ -303,6 +314,25 @@ describe('WorkflowReviewRequestService.updateVersion', () => {
|
|||
expect(collaborationService.broadcastWorkflowReviewStateChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to re-pin a workflow archived while the update waited for the lock', async () => {
|
||||
mockSuccessfulUpdatePath();
|
||||
// The pre-lock lookups still see a live workflow; only the in-lock re-read
|
||||
// sees the archive that committed while this update queued.
|
||||
workflowEntityRepository.findArchivedState.mockResolvedValue({ isArchived: true });
|
||||
|
||||
const update = service.updateVersion(user, requestId, dto);
|
||||
await expect(update).rejects.toThrow(BadRequestError);
|
||||
await expect(update).rejects.toThrow(
|
||||
"The workflow 'wf-1' is archived and cannot be submitted as a new review version",
|
||||
);
|
||||
|
||||
// Nothing may reach the activity feed: a version_updated entry here would
|
||||
// durably assert a re-pin on a workflow that can no longer be reviewed.
|
||||
expect(activityRepository.createActivity).not.toHaveBeenCalled();
|
||||
expect(workflowRepository.updateWorkflowVersion).not.toHaveBeenCalled();
|
||||
expect(requestRepository.saveRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the request disappears between check and lock', async () => {
|
||||
mockSuccessfulUpdatePath();
|
||||
requestRepository.findById.mockResolvedValueOnce(openRequest()).mockResolvedValueOnce(null);
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ beforeEach(async () => {
|
|||
testServer.license.enable('feat:workflowReviews');
|
||||
|
||||
await testDb.truncate([
|
||||
'WorkflowReviewActivityComment',
|
||||
'WorkflowReviewActivity',
|
||||
'WorkflowReviewRequestAuthor',
|
||||
'WorkflowReviewRequestReviewer',
|
||||
'WorkflowReviewRequestWorkflow',
|
||||
|
|
@ -1535,7 +1537,7 @@ describe('POST /workflow-review-requests/:workflowReviewRequestId/decision', ()
|
|||
|
||||
const response = await memberAgent
|
||||
.post(`/workflow-review-requests/${request.id}/decision`)
|
||||
.send({ decision: 'changes_requested' })
|
||||
.send({ decision: 'changes_requested', note: 'Please rename the node' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toMatchObject({
|
||||
|
|
@ -1576,7 +1578,7 @@ describe('POST /workflow-review-requests/:workflowReviewRequestId/decision', ()
|
|||
|
||||
await memberAgent
|
||||
.post(`/workflow-review-requests/${request.id}/decision`)
|
||||
.send({ decision: 'changes_requested' })
|
||||
.send({ decision: 'changes_requested', note: 'Please rename the node' })
|
||||
.expect(200);
|
||||
|
||||
expect(await requestRepository.findById(request.id, {})).toMatchObject({
|
||||
|
|
@ -1665,7 +1667,7 @@ describe('POST /workflow-review-requests/:workflowReviewRequestId/decision', ()
|
|||
|
||||
await viewerAgent
|
||||
.post(`/workflow-review-requests/${request.id}/decision`)
|
||||
.send({ decision: 'changes_requested' })
|
||||
.send({ decision: 'changes_requested', note: 'Please rename the node' })
|
||||
.expect(200);
|
||||
|
||||
expect(await requestRepository.findById(request.id, {})).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { WorkflowReviewActivity } from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { toActivityEntry } from '../workflow-review.mapper';
|
||||
|
||||
describe('toActivityEntry', () => {
|
||||
const logger = mock<Logger>();
|
||||
|
||||
// The stored `type` is CHECK-constrained, so only a downgrade to a version that knows fewer
|
||||
// types can produce this row — which is why it cannot be reached through the endpoints.
|
||||
it('serves an entry of a type this version does not know rather than dropping it', () => {
|
||||
const row = mock<WorkflowReviewActivity>({
|
||||
id: 7,
|
||||
typeVersion: 1,
|
||||
type: 'review.something_newer' as WorkflowReviewActivity['type'],
|
||||
data: { anything: true },
|
||||
createdById: null,
|
||||
createdAt: new Date('2026-07-20T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(toActivityEntry(row, [], new Map(), logger)).toEqual({
|
||||
id: '7',
|
||||
typeVersion: 1,
|
||||
type: 'review.something_newer',
|
||||
data: null,
|
||||
createdBy: null,
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
// Same reason: only a downgrade writes a payload version this code does not know, and its
|
||||
// shape may happen to satisfy the version 1 schema while meaning something else.
|
||||
it('does not read a newer payload version as if it were the one it knows', () => {
|
||||
const row = mock<WorkflowReviewActivity>({
|
||||
id: 8,
|
||||
typeVersion: 2,
|
||||
type: 'review.approved',
|
||||
data: {
|
||||
workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'ver-1' }],
|
||||
note: 'Ship it',
|
||||
},
|
||||
createdById: null,
|
||||
createdAt: new Date('2026-07-20T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(toActivityEntry(row, [], new Map(), logger).data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
WorkflowReviewActivityEntry,
|
||||
WorkflowReviewEligibleReviewer,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
TransactionRunner,
|
||||
UserRepository,
|
||||
|
|
@ -33,6 +34,7 @@ export class WorkflowReviewActivityService {
|
|||
private readonly activityCommentRepository: WorkflowReviewActivityCommentRepository,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly txRunner: TransactionRunner,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async listActivity(
|
||||
|
|
@ -96,7 +98,12 @@ export class WorkflowReviewActivityService {
|
|||
return { activity, message };
|
||||
});
|
||||
|
||||
return toActivityEntry(activity, [message], new Map([[user.id, toEligibleReviewer(user)]]));
|
||||
return toActivityEntry(
|
||||
activity,
|
||||
[message],
|
||||
new Map([[user.id, toEligibleReviewer(user)]]),
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
private async hydrate(
|
||||
|
|
@ -109,7 +116,9 @@ export class WorkflowReviewActivityService {
|
|||
]),
|
||||
);
|
||||
|
||||
return entries.map((entry) => toActivityEntry(entry.activity, entry.messages, usersById));
|
||||
return entries.map((entry) =>
|
||||
toActivityEntry(entry.activity, entry.messages, usersById, this.logger),
|
||||
);
|
||||
}
|
||||
|
||||
private async hydrateAuthors(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import type { WorkflowReviewClosedReason } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { DbLock, DbLockService, WorkflowReviewRequestRepository } from '@n8n/db';
|
||||
import {
|
||||
DbLock,
|
||||
DbLockService,
|
||||
WorkflowReviewActivityRepository,
|
||||
WorkflowReviewRequestRepository,
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { CollaborationService } from '@/collaboration/collaboration.service';
|
||||
import type { WorkflowMutationHooks } from '@/workflows/workflow-mutation-hooks-proxy.service';
|
||||
|
||||
type AutoCloseReason = 'workflow-archived' | 'workflow-moved' | 'workflow-deleted';
|
||||
|
||||
/**
|
||||
* Closes open review requests when their workflow stops being reviewable:
|
||||
* archived, moved to another project, or deleted.
|
||||
|
|
@ -21,16 +25,19 @@ export class WorkflowReviewAutoCloseService implements WorkflowMutationHooks {
|
|||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly workflowReviewRequestRepository: WorkflowReviewRequestRepository,
|
||||
private readonly activityRepository: WorkflowReviewActivityRepository,
|
||||
private readonly dbLockService: DbLockService,
|
||||
private readonly collaborationService: CollaborationService,
|
||||
) {}
|
||||
|
||||
async afterWorkflowArchived(workflowId: string): Promise<void> {
|
||||
await this.closeOpenRequestsForWorkflows([workflowId], 'workflow-archived');
|
||||
await this.reconcileUnreviewableOpenRequests([workflowId]);
|
||||
}
|
||||
|
||||
async afterWorkflowsTransferred(workflowIds: string[]): Promise<void> {
|
||||
await this.closeOpenRequestsForWorkflows(workflowIds, 'workflow-moved');
|
||||
await this.reconcileUnreviewableOpenRequests(workflowIds);
|
||||
}
|
||||
|
||||
async beforeWorkflowDeleted(workflowId: string): Promise<void> {
|
||||
|
|
@ -41,38 +48,71 @@ export class WorkflowReviewAutoCloseService implements WorkflowMutationHooks {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes reviews whose workflow is gone: the cascade took their link rows, so they
|
||||
* can only be found by "open with nothing linked", not by workflow id — the sweep is
|
||||
* global and one call covers the whole batch. Catches reviews opened after the
|
||||
* pre-delete hooks ran, and any left by a delete that skips the hooks.
|
||||
*/
|
||||
async afterWorkflowsDeleted(workflowIds: string[]): Promise<void> {
|
||||
await this.reconcileUnreviewableOpenRequests(workflowIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes every open review whose workflow is no longer reviewable, whatever left it that way.
|
||||
* The sweep is global — one call covers the whole batch, and the ids are log context only.
|
||||
*
|
||||
* The per-mutation close above cannot cover two cases. A delete cascades the link rows away,
|
||||
* so the reviews it strands are findable only as "open with nothing linked", never by workflow
|
||||
* id. And an archive or a move whose close rolled back stays committed, leaving a review its
|
||||
* own hook has already finished with — so the sweep runs after those hooks too, in its own
|
||||
* transaction, and repairs the strand on that mutation or on the next one.
|
||||
*
|
||||
* No broadcast: the reviews it closes are a rare inconsistency rather than a live edit, and
|
||||
* viewers heal on the next focus/reconnect refetch.
|
||||
*/
|
||||
private async reconcileUnreviewableOpenRequests(workflowIds: string[]): Promise<void> {
|
||||
try {
|
||||
const closedRequestIds = await this.workflowReviewRequestRepository.closeOrphanedOpenRequests(
|
||||
{},
|
||||
const closedRequests = await this.dbLockService.withLockContext(
|
||||
DbLock.WORKFLOW_REVIEW_REQUEST_CREATE,
|
||||
async (ctx) => {
|
||||
const requests =
|
||||
await this.workflowReviewRequestRepository.closeUnreviewableOpenRequests(ctx);
|
||||
|
||||
// Under the same lock and in the same transaction as every other close path: the
|
||||
// sweep selects the requests and then updates them by id, so two sweeps racing
|
||||
// across that gap would both explain the same close and both overwrite whatever a
|
||||
// concurrent approval wrote. A failed entry rolls the close back and the next
|
||||
// sweep closes it again, which is what the sweep is for.
|
||||
for (const { id, reason } of requests) {
|
||||
await this.activityRepository.createActivity(
|
||||
{
|
||||
workflowReviewRequestId: id,
|
||||
type: 'review.closed',
|
||||
data: { reason },
|
||||
createdById: null,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
return requests;
|
||||
},
|
||||
);
|
||||
|
||||
if (closedRequestIds.length === 0) return;
|
||||
if (closedRequests.length === 0) return;
|
||||
|
||||
this.logger.info('Closed workflow review request(s) left without a workflow', {
|
||||
reason: 'workflow-deleted',
|
||||
this.logger.info('Closed open workflow review request(s) left on unreviewable workflows', {
|
||||
workflowIds,
|
||||
workflowReviewRequestIds: closedRequestIds,
|
||||
closedRequests,
|
||||
});
|
||||
} catch (error) {
|
||||
// The delete has already committed; failing it now would be worse than a
|
||||
// The mutation has already committed; failing it now would be worse than a
|
||||
// request that stays open until the next sweep closes it.
|
||||
this.logger.error('Failed to close workflow review request(s) left without a workflow', {
|
||||
workflowIds,
|
||||
error,
|
||||
});
|
||||
this.logger.error(
|
||||
'Failed to close open workflow review request(s) left on unreviewable workflows',
|
||||
{ workflowIds, error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOpenRequestsForWorkflows(
|
||||
workflowIds: string[],
|
||||
reason: AutoCloseReason,
|
||||
reason: WorkflowReviewClosedReason,
|
||||
options: { rethrow?: boolean } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
|
|
@ -93,6 +133,21 @@ export class WorkflowReviewAutoCloseService implements WorkflowMutationHooks {
|
|||
// A system close has no closing user; the decision stays as-is.
|
||||
request.closedById = null;
|
||||
await this.workflowReviewRequestRepository.saveRequest(request, ctx);
|
||||
|
||||
// In the transaction on purpose: a review closed without an explanation is
|
||||
// worse than a close that rolls back. Before a delete the rollback calls the
|
||||
// delete off; for an archive or a move the mutation stands and the review
|
||||
// stays open until the reconciliation sweep closes it again.
|
||||
await this.activityRepository.createActivity(
|
||||
{
|
||||
workflowReviewRequestId: request.id,
|
||||
type: 'review.closed',
|
||||
data: { reason },
|
||||
createdById: null,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
for (const linkedWorkflowId of linkedWorkflowIds) affected.add(linkedWorkflowId);
|
||||
}
|
||||
|
||||
|
|
@ -121,8 +176,9 @@ export class WorkflowReviewAutoCloseService implements WorkflowMutationHooks {
|
|||
workflowIds,
|
||||
error,
|
||||
});
|
||||
// Cleanup must never fail a mutation that already committed; only the
|
||||
// pre-delete hook is still in a position to call its mutation off.
|
||||
// Cleanup does not fail a mutation that already committed — archive and move end
|
||||
// here, having rolled the close back. Only the pre-delete hook is still in a
|
||||
// position to call its mutation off.
|
||||
if (options.rethrow) throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
WorkflowReviewRequestAuthorRepository,
|
||||
WorkflowReviewRequestRepository,
|
||||
WorkflowRepository,
|
||||
WorkflowReviewActivityRepository,
|
||||
WorkflowReviewRequestReviewerRepository,
|
||||
WorkflowReviewRequestWorkflowRepository,
|
||||
type OperationContext,
|
||||
|
|
@ -58,6 +59,15 @@ function normalizeReviewDescription(description: string | undefined): string | n
|
|||
return description.trim() || null;
|
||||
}
|
||||
|
||||
/** What the caller was trying to do, so a refusal names that action rather than another. */
|
||||
type BlockedAction = 'submit' | 'review' | 'update';
|
||||
|
||||
const BLOCKED_ACTION_TEXT: Record<BlockedAction, string> = {
|
||||
submit: 'submitted for review',
|
||||
review: 'reviewed',
|
||||
update: 'submitted as a new review version',
|
||||
};
|
||||
|
||||
/**
|
||||
* The workflow-scoped review request lifecycle: listing a workflow's reviews,
|
||||
* resolving its eligible reviewers, opening a review, re-pinning it to a new
|
||||
|
|
@ -79,6 +89,7 @@ export class WorkflowReviewRequestService {
|
|||
private readonly workflowReviewRequestWorkflowRepository: WorkflowReviewRequestWorkflowRepository,
|
||||
private readonly workflowReviewRequestAuthorRepository: WorkflowReviewRequestAuthorRepository,
|
||||
private readonly workflowReviewRequestReviewerRepository: WorkflowReviewRequestReviewerRepository,
|
||||
private readonly activityRepository: WorkflowReviewActivityRepository,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly eligibilityService: WorkflowReviewEligibilityService,
|
||||
private readonly roleService: RoleService,
|
||||
|
|
@ -289,7 +300,7 @@ export class WorkflowReviewRequestService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Re-asserts under the lock what `create`'s pre-lock checks established: the
|
||||
* Re-asserts under the lock what the caller's pre-lock checks established: the
|
||||
* workflow still exists, is unarchived, and still belongs to the same project.
|
||||
* Access is not re-checked — the races this guards against are archive and
|
||||
* transfer, both of which these two reads cover.
|
||||
|
|
@ -302,7 +313,9 @@ export class WorkflowReviewRequestService {
|
|||
workflowId: string,
|
||||
expectedProjectId: string,
|
||||
ctx: OperationContext,
|
||||
action: BlockedAction,
|
||||
): Promise<void> {
|
||||
const blockedAction = BLOCKED_ACTION_TEXT[action];
|
||||
const workflow = await this.workflowRepository.findArchivedState(workflowId, ctx);
|
||||
if (!workflow) {
|
||||
throw new NotFoundError('Could not find workflow');
|
||||
|
|
@ -310,14 +323,14 @@ export class WorkflowReviewRequestService {
|
|||
|
||||
if (workflow.isArchived) {
|
||||
throw new BadRequestError(
|
||||
`The workflow '${workflowId}' is archived and cannot be submitted for review`,
|
||||
`The workflow '${workflowId}' is archived and cannot be ${blockedAction}`,
|
||||
);
|
||||
}
|
||||
|
||||
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflowId, ctx);
|
||||
if (project?.id !== expectedProjectId) {
|
||||
throw new ConflictError(
|
||||
`The workflow '${workflowId}' moved to another project and cannot be submitted for review here`,
|
||||
`The workflow '${workflowId}' moved to another project and cannot be ${blockedAction} here`,
|
||||
'Retry from the project that now owns the workflow',
|
||||
);
|
||||
}
|
||||
|
|
@ -384,7 +397,7 @@ export class WorkflowReviewRequestService {
|
|||
async (ctx) => {
|
||||
// without this, a create that lost the race opens a review on an archived
|
||||
// or moved workflow.
|
||||
await this.assertWorkflowStillReviewable(workflowId, project.id, ctx);
|
||||
await this.assertWorkflowStillReviewable(workflowId, project.id, ctx, 'submit');
|
||||
|
||||
const existing = await this.workflowReviewRequestRepository.findOpenRequestForWorkflow(
|
||||
workflowId,
|
||||
|
|
@ -430,6 +443,19 @@ export class WorkflowReviewRequestService {
|
|||
ctx,
|
||||
);
|
||||
|
||||
// Records the opening version: the pin only ever holds the current one, and
|
||||
// pruning spares it only while the review is open, so a re-pin or a close
|
||||
// leaves the original prunable and its `SET NULL` erases the last trace.
|
||||
await this.activityRepository.createActivity(
|
||||
{
|
||||
workflowReviewRequestId: created.id,
|
||||
type: 'review.opened',
|
||||
data: { workflowVersions: [{ workflowId, workflowVersionId }] },
|
||||
createdById: user.id,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
return created;
|
||||
},
|
||||
);
|
||||
|
|
@ -547,6 +573,16 @@ export class WorkflowReviewRequestService {
|
|||
if (!currentRow) {
|
||||
throw new NotFoundError('Could not find review request');
|
||||
}
|
||||
|
||||
// Archive and transfer run in after hooks, so they commit before queueing
|
||||
// on this lock — the pre-lock check can already be stale here.
|
||||
await this.assertWorkflowStillReviewable(
|
||||
currentRow.workflowId,
|
||||
current.projectId,
|
||||
ctx,
|
||||
'update',
|
||||
);
|
||||
|
||||
if (currentRow.workflowVersionId === dto.workflowVersionId) {
|
||||
// A concurrent sync won the lock and already re-pinned this version
|
||||
// but our rename or re-description can still be pending — apply it
|
||||
|
|
@ -572,6 +608,9 @@ export class WorkflowReviewRequestService {
|
|||
return { request: saved, changed: true };
|
||||
}
|
||||
|
||||
// Captured before the update.
|
||||
const fromWorkflowVersionId = currentRow.workflowVersionId;
|
||||
|
||||
await this.workflowReviewRequestWorkflowRepository.updateWorkflowVersion(
|
||||
{
|
||||
workflowReviewRequestId,
|
||||
|
|
@ -601,6 +640,20 @@ export class WorkflowReviewRequestService {
|
|||
ctx,
|
||||
);
|
||||
|
||||
await this.activityRepository.createActivity(
|
||||
{
|
||||
workflowReviewRequestId,
|
||||
type: 'review.version_updated',
|
||||
data: {
|
||||
workflowId: dto.workflowId,
|
||||
fromWorkflowVersionId,
|
||||
toWorkflowVersionId: dto.workflowVersionId,
|
||||
},
|
||||
createdById: user.id,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
return { request: saved, changed: true };
|
||||
},
|
||||
);
|
||||
|
|
@ -680,6 +733,13 @@ export class WorkflowReviewRequestService {
|
|||
? await this.resolveRequesterPublishability(request.createdById, workflowRow.workflowId)
|
||||
: null;
|
||||
|
||||
// After the lifecycle and permission checks: a state or permission error outranks a
|
||||
// payload error, so an author keeps being told they may not decide at all rather than
|
||||
// that their note was the problem.
|
||||
if (dto.decision === 'changes_requested' && !dto.note) {
|
||||
throw new BadRequestError('A note is required when requesting changes');
|
||||
}
|
||||
|
||||
const { request: saved, pinnedVersionId } = await this.dbLockService.withLockContext(
|
||||
DbLock.WORKFLOW_REVIEW_REQUEST_CREATE,
|
||||
async (ctx) => {
|
||||
|
|
@ -719,6 +779,17 @@ export class WorkflowReviewRequestService {
|
|||
throw new NotFoundError('Could not find review request');
|
||||
}
|
||||
|
||||
// Archive and transfer run in after hooks, so they commit before queueing
|
||||
// on this lock — the pre-lock check can already be stale here.
|
||||
// 'reviewed' covers both decisions: an approval and a change request
|
||||
// are equally refused here.
|
||||
await this.assertWorkflowStillReviewable(
|
||||
currentRow.workflowId,
|
||||
current.projectId,
|
||||
ctx,
|
||||
'review',
|
||||
);
|
||||
|
||||
current.decision = dto.decision;
|
||||
current.updatedById = user.id;
|
||||
if (dto.decision === 'approved') {
|
||||
|
|
@ -730,6 +801,25 @@ export class WorkflowReviewRequestService {
|
|||
}
|
||||
|
||||
const savedRequest = await this.workflowReviewRequestRepository.saveRequest(current, ctx);
|
||||
|
||||
await this.activityRepository.createActivity(
|
||||
{
|
||||
workflowReviewRequestId,
|
||||
type: dto.decision === 'approved' ? 'review.approved' : 'review.changes_requested',
|
||||
data: {
|
||||
// A pruned pin drops out rather than landing as a null in the array.
|
||||
workflowVersions: currentRows.flatMap((row) =>
|
||||
row.workflowVersionId === null
|
||||
? []
|
||||
: [{ workflowId: row.workflowId, workflowVersionId: row.workflowVersionId }],
|
||||
),
|
||||
note: dto.note ?? null,
|
||||
},
|
||||
createdById: user.id,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
return { request: savedRequest, pinnedVersionId: currentRow.workflowVersionId };
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import type { WorkflowReviewActivityEntry, WorkflowReviewEligibleReviewer } from '@n8n/api-types';
|
||||
import {
|
||||
workflowReviewClosedActivityDataSchema,
|
||||
workflowReviewDecisionActivityDataSchema,
|
||||
workflowReviewOpenedActivityDataSchema,
|
||||
workflowReviewVersionUpdatedActivityDataSchema,
|
||||
type WorkflowReviewActivityEntry,
|
||||
type WorkflowReviewEligibleReviewer,
|
||||
} from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { User, WorkflowReviewActivity, WorkflowReviewActivityComment } from '@n8n/db';
|
||||
import type { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Project a user onto the boundary shape the review endpoints are allowed to expose.
|
||||
|
|
@ -15,10 +24,36 @@ export function toEligibleReviewer(user: User): WorkflowReviewEligibleReviewer {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored payload the schema rejects becomes `null` rather than an exception: one bad row must
|
||||
* not take down the whole feed page.
|
||||
*/
|
||||
function parsePayload<T>(
|
||||
schema: z.ZodType<T>,
|
||||
row: WorkflowReviewActivity,
|
||||
logger: Logger,
|
||||
): T | null {
|
||||
// These schemas describe typeVersion 1 only; a later version gets its own schema and case.
|
||||
if (row.typeVersion === 1) {
|
||||
const parsed = schema.safeParse(row.data);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('Failed to parse workflow review activity payload', {
|
||||
activityId: row.id,
|
||||
type: row.type,
|
||||
typeVersion: row.typeVersion,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toActivityEntry(
|
||||
row: WorkflowReviewActivity,
|
||||
messages: WorkflowReviewActivityComment[],
|
||||
usersById: Map<string, WorkflowReviewEligibleReviewer>,
|
||||
logger: Logger,
|
||||
): WorkflowReviewActivityEntry {
|
||||
const createdBy = row.createdById ? (usersById.get(row.createdById) ?? null) : null;
|
||||
const base = {
|
||||
|
|
@ -28,23 +63,53 @@ export function toActivityEntry(
|
|||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
if (row.type === 'comment.created') {
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
data: null,
|
||||
messages: messages.map((message) => ({
|
||||
id: String(message.id),
|
||||
// Never send the body of a deleted comment, even if the row still holds one, so a
|
||||
// delete path that only sets `deletedAt` cannot leak it.
|
||||
body: message.deletedAt ? null : message.body,
|
||||
createdBy: message.createdById ? (usersById.get(message.createdById) ?? null) : null,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
updatedAt: message.updatedAt?.toISOString() ?? null,
|
||||
deletedAt: message.deletedAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
switch (row.type) {
|
||||
case 'comment.created':
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
data: null,
|
||||
messages: messages.map((message) => ({
|
||||
id: String(message.id),
|
||||
// Never send the body of a deleted comment, even if the row still holds one, so a
|
||||
// delete path that only sets `deletedAt` cannot leak it.
|
||||
body: message.deletedAt ? null : message.body,
|
||||
createdBy: message.createdById ? (usersById.get(message.createdById) ?? null) : null,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
updatedAt: message.updatedAt?.toISOString() ?? null,
|
||||
deletedAt: message.deletedAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
case 'review.opened':
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
data: parsePayload(workflowReviewOpenedActivityDataSchema, row, logger),
|
||||
};
|
||||
case 'review.changes_requested':
|
||||
case 'review.approved':
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
data: parsePayload(workflowReviewDecisionActivityDataSchema, row, logger),
|
||||
};
|
||||
case 'review.version_updated':
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
data: parsePayload(workflowReviewVersionUpdatedActivityDataSchema, row, logger),
|
||||
};
|
||||
case 'review.closed':
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
data: parsePayload(workflowReviewClosedActivityDataSchema, row, logger),
|
||||
};
|
||||
case 'workflow.published':
|
||||
return { ...base, type: row.type, data: null };
|
||||
}
|
||||
|
||||
return { ...base, type: row.type, data: row.data };
|
||||
// Unreachable per the union; a row whose type is outside it (e.g. after a version
|
||||
// downgrade) must still render as an unknown entry rather than crash the feed.
|
||||
return { ...base, type: row.type, data: null };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5485,13 +5485,19 @@
|
|||
"workflowReviews.decision.approved": "Approved",
|
||||
"workflowReviews.detail.tabs.activity": "Activity",
|
||||
"workflowReviews.detail.tabs.changes": "Changes",
|
||||
"workflowReviews.detail.activity.description": "Description",
|
||||
"workflowReviews.detail.activity.noDescription": "No description provided.",
|
||||
"workflowReviews.detail.activity.listLabel": "Review activity",
|
||||
"workflowReviews.detail.activity.empty.heading": "No activity yet",
|
||||
"workflowReviews.detail.activity.empty.description": "Comments and review activity will appear here.",
|
||||
"workflowReviews.detail.activity.comment.deleted": "This comment was deleted.",
|
||||
"workflowReviews.detail.activity.unknownEntry": "This activity entry can't be displayed.",
|
||||
"workflowReviews.detail.activity.unknownAuthor": "Deleted user",
|
||||
"workflowReviews.detail.activity.opened": "Review submitted",
|
||||
"workflowReviews.detail.activity.changesRequested": "Requested changes",
|
||||
"workflowReviews.detail.activity.approved": "Approved submission",
|
||||
"workflowReviews.detail.activity.versionUpdated": "New workflow version submitted",
|
||||
"workflowReviews.detail.activity.closed.archived": "Review closed because the workflow was archived",
|
||||
"workflowReviews.detail.activity.closed.moved": "Review closed because the workflow moved to another project",
|
||||
"workflowReviews.detail.activity.closed.deleted": "Review closed because the workflow was deleted",
|
||||
"workflowReviews.detail.activity.composer.label": "Add a comment",
|
||||
"workflowReviews.detail.activity.composer.placeholder": "Leave a comment...",
|
||||
"workflowReviews.detail.activity.composer.notAllowed": "You don't have permission to comment on this review.",
|
||||
|
|
@ -5515,7 +5521,11 @@
|
|||
"workflowReviews.changes.firstPublish.sourceEmpty": "This workflow hasn't been published before.",
|
||||
"workflowReviews.detail.notFound.title": "Review not found",
|
||||
"workflowReviews.detail.notFound.body": "This review doesn't exist or you don't have access to it.",
|
||||
"workflowReviews.detail.decision.approve": "Approve",
|
||||
"workflowReviews.detail.decision.trigger": "Review",
|
||||
"workflowReviews.detail.decision.note.label": "Review note",
|
||||
"workflowReviews.detail.decision.note.required": "Add a note to request changes",
|
||||
"workflowReviews.detail.decision.comment": "Comment",
|
||||
"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.body.open": "There are no open reviews right now.",
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ import { useReviewActivityStore } from '../reviewActivity.store';
|
|||
import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
|
||||
|
||||
/**
|
||||
* Scroll anchoring and the first-load scroll to bottom are deliberately untested:
|
||||
* jsdom has no layout, so `scrollHeight` is always 0 and `getBoundingClientRect()`
|
||||
* all zeroes. A test would have to stub those getters and then re-compute the
|
||||
* production arithmetic from its own constants. Do not backfill a fake one.
|
||||
* The scroll anchoring arithmetic is deliberately untested: jsdom has no layout, so
|
||||
* `getBoundingClientRect()` is all zeroes and a test would have to stub those getters and then
|
||||
* re-compute the production arithmetic from its own constants. Do not backfill a fake one.
|
||||
* Which of the two behaviours a changed list picks — hold the reading position, or jump to the
|
||||
* newest entry — is testable, and is what the reload cases below assert.
|
||||
*/
|
||||
const observer = vi.hoisted(() => ({
|
||||
observe: vi.fn(),
|
||||
|
|
@ -27,7 +28,9 @@ vi.mock('@/app/composables/useIntersectionObserver', () => ({
|
|||
|
||||
const renderComponent = createComponentRenderer(WorkflowReviewActivityFeed);
|
||||
|
||||
function makeEntry(overrides: Partial<WorkflowReviewActivityEntry> = {}) {
|
||||
type CommentEntry = Extract<WorkflowReviewActivityEntry, { type: 'comment.created' }>;
|
||||
|
||||
function makeComment(overrides: Partial<CommentEntry> = {}): WorkflowReviewActivityEntry {
|
||||
return {
|
||||
id: '1',
|
||||
type: 'comment.created',
|
||||
|
|
@ -46,9 +49,17 @@ function makeEntry(overrides: Partial<WorkflowReviewActivityEntry> = {}) {
|
|||
},
|
||||
],
|
||||
...overrides,
|
||||
} as WorkflowReviewActivityEntry;
|
||||
};
|
||||
}
|
||||
|
||||
/** Every non-comment entry shares these fields; `type` and `data` are per row. */
|
||||
const systemEntry = {
|
||||
id: '1',
|
||||
typeVersion: 1,
|
||||
createdBy: null,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('WorkflowReviewActivityFeed', () => {
|
||||
let store: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
|
||||
|
||||
|
|
@ -66,7 +77,7 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
});
|
||||
|
||||
it('shows one entry per activity in the feed', async () => {
|
||||
store.entries = Array.from({ length: 3 }, (_, index) => makeEntry({ id: String(index) }));
|
||||
store.entries = Array.from({ length: 3 }, (_, index) => makeComment({ id: String(index) }));
|
||||
|
||||
const { queryAllByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
|
@ -83,12 +94,6 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
expect(queryByTestId('workflow-review-activity-empty')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tells the viewer there is no activity yet', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('workflow-review-activity-empty')).toHaveTextContent('No activity yet');
|
||||
});
|
||||
|
||||
it('shows an error row when the first page failed', () => {
|
||||
store.error = new Error('boom');
|
||||
|
||||
|
|
@ -112,7 +117,7 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
|
||||
it('still reaches the earlier activity after posting onto a feed that failed to load', () => {
|
||||
store.error = new Error('boom');
|
||||
store.entries = [makeEntry()];
|
||||
store.entries = [makeComment()];
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
getByTestId('workflow-review-activity-load-more-retry').click();
|
||||
|
|
@ -122,7 +127,7 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
});
|
||||
|
||||
it('shows progress while refetching a feed that already has entries', async () => {
|
||||
store.entries = [makeEntry()];
|
||||
store.entries = [makeComment()];
|
||||
store.loading = true;
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
|
@ -132,7 +137,7 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
});
|
||||
|
||||
it('keeps a loaded feed and offers a retry when load-more failed', async () => {
|
||||
store.entries = [makeEntry()];
|
||||
store.entries = [makeComment()];
|
||||
store.hasMore = true;
|
||||
// A load-more failure always has a cursor to resume from.
|
||||
store.nextCursor = 'cursor-1';
|
||||
|
|
@ -161,7 +166,7 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
});
|
||||
|
||||
it('shows a placeholder for an activity type this frontend does not know', async () => {
|
||||
store.entries = [makeEntry({ typeVersion: 2 })];
|
||||
store.entries = [makeComment({ typeVersion: 2 })];
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
|
@ -172,7 +177,7 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
});
|
||||
|
||||
it('loads older entries when the viewer scrolls to the top of the feed', async () => {
|
||||
store.entries = [makeEntry()];
|
||||
store.entries = [makeComment()];
|
||||
store.hasMore = true;
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
|
@ -189,7 +194,7 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
});
|
||||
|
||||
it('stops loading once the oldest entry is on screen', async () => {
|
||||
store.entries = [makeEntry()];
|
||||
store.entries = [makeComment()];
|
||||
store.hasMore = false;
|
||||
|
||||
renderComponent();
|
||||
|
|
@ -199,8 +204,47 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
expect(observer.observe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* A reload keeps only the entries newer than the page it fetched, so its first entry
|
||||
* differs without anything having been prepended. `scrollHeight` is stubbed because jsdom
|
||||
* has no layout; the assertion is on the feed landing at the bottom, not on any arithmetic.
|
||||
*/
|
||||
async function reloadFeedAndReturnContainer(getByTestId: (id: string) => HTMLElement) {
|
||||
const container = getByTestId('workflow-review-activity-feed');
|
||||
Object.defineProperty(container, 'scrollHeight', { value: 500, configurable: true });
|
||||
container.scrollTop = 120;
|
||||
|
||||
store.entries = [makeComment({ id: '2' }), makeComment({ id: '3' })];
|
||||
await nextTick();
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
it('shows the entry a decision just added to a feed the viewer had paged back through', async () => {
|
||||
store.entries = [makeComment({ id: '1' }), makeComment({ id: '2' })];
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect((await reloadFeedAndReturnContainer(getByTestId)).scrollTop).toBe(500);
|
||||
});
|
||||
|
||||
// `loadMore` returns early while another page is in flight, leaving behind the anchor it
|
||||
// captured — which by the next reload points at an entry no longer on screen.
|
||||
it('shows the newest entry after a reload that followed a load-more that never ran', async () => {
|
||||
store.entries = [makeComment({ id: '1' }), makeComment({ id: '2' })];
|
||||
store.hasMore = true;
|
||||
store.loadingMore = true;
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
observer.onIntersect();
|
||||
|
||||
expect((await reloadFeedAndReturnContainer(getByTestId)).scrollTop).toBe(500);
|
||||
});
|
||||
|
||||
it('does not request the same older page twice', async () => {
|
||||
store.entries = [makeEntry()];
|
||||
store.entries = [makeComment()];
|
||||
store.hasMore = true;
|
||||
store.loadingMore = true;
|
||||
|
||||
|
|
@ -210,4 +254,214 @@ describe('WorkflowReviewActivityFeed', () => {
|
|||
|
||||
expect(observer.observe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('review lifecycle entries', () => {
|
||||
it.each<[string, WorkflowReviewActivityEntry, { testId: string; text: string; note?: string }]>(
|
||||
[
|
||||
[
|
||||
'the review being submitted',
|
||||
{
|
||||
...systemEntry,
|
||||
type: 'review.opened',
|
||||
data: { workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }] },
|
||||
},
|
||||
{ testId: 'workflow-review-activity-opened', text: 'Review submitted' },
|
||||
],
|
||||
[
|
||||
'requested changes with the note the reviewer left',
|
||||
{
|
||||
...systemEntry,
|
||||
type: 'review.changes_requested',
|
||||
data: {
|
||||
workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
|
||||
note: 'Please add retries',
|
||||
},
|
||||
},
|
||||
{
|
||||
testId: 'workflow-review-activity-changes-requested',
|
||||
text: 'Requested changes',
|
||||
note: 'Please add retries',
|
||||
},
|
||||
],
|
||||
[
|
||||
'an approval with the note the reviewer left',
|
||||
{
|
||||
...systemEntry,
|
||||
type: 'review.approved',
|
||||
data: {
|
||||
workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
|
||||
note: 'Ship it',
|
||||
},
|
||||
},
|
||||
{
|
||||
testId: 'workflow-review-activity-approved',
|
||||
text: 'Approved submission',
|
||||
note: 'Ship it',
|
||||
},
|
||||
],
|
||||
[
|
||||
'an approval given without a note',
|
||||
{
|
||||
...systemEntry,
|
||||
type: 'review.approved',
|
||||
data: {
|
||||
workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
|
||||
note: null,
|
||||
},
|
||||
},
|
||||
{ testId: 'workflow-review-activity-approved', text: 'Approved submission' },
|
||||
],
|
||||
[
|
||||
'an approval whose stored payload can no longer be read',
|
||||
{ ...systemEntry, type: 'review.approved', data: null },
|
||||
{ testId: 'workflow-review-activity-approved', text: 'Approved submission' },
|
||||
],
|
||||
[
|
||||
'the reviewed version being updated',
|
||||
{
|
||||
...systemEntry,
|
||||
type: 'review.version_updated',
|
||||
data: {
|
||||
workflowId: 'wf-1',
|
||||
fromWorkflowVersionId: 'version-1',
|
||||
toWorkflowVersionId: 'version-2',
|
||||
},
|
||||
},
|
||||
{
|
||||
testId: 'workflow-review-activity-version-updated',
|
||||
text: 'New workflow version submitted',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a review closed by archiving its workflow',
|
||||
{ ...systemEntry, type: 'review.closed', data: { reason: 'workflow-archived' } },
|
||||
{
|
||||
testId: 'workflow-review-activity-closed',
|
||||
text: 'Review closed because the workflow was archived',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a review closed by moving its workflow',
|
||||
{ ...systemEntry, type: 'review.closed', data: { reason: 'workflow-moved' } },
|
||||
{
|
||||
testId: 'workflow-review-activity-closed',
|
||||
text: 'Review closed because the workflow moved to another project',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a review closed by deleting its workflow',
|
||||
{ ...systemEntry, type: 'review.closed', data: { reason: 'workflow-deleted' } },
|
||||
{
|
||||
testId: 'workflow-review-activity-closed',
|
||||
text: 'Review closed because the workflow was deleted',
|
||||
},
|
||||
],
|
||||
],
|
||||
)('shows %s', async (_label, entry, expected) => {
|
||||
store.entries = [entry];
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId(expected.testId)).toHaveTextContent(expected.text);
|
||||
if (expected.note) {
|
||||
expect(getByTestId('workflow-review-activity-note')).toHaveTextContent(expected.note);
|
||||
} else {
|
||||
expect(queryByTestId('workflow-review-activity-note')).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
// The only entry whose payload the sentence cannot do without: the close reason *is* the
|
||||
// sentence, so there is nothing left to render.
|
||||
it('shows a placeholder for a closed review that no longer says why', async () => {
|
||||
store.entries = [{ ...systemEntry, type: 'review.closed', data: null }];
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('workflow-review-activity-unknown')).toHaveTextContent(
|
||||
"This activity entry can't be displayed.",
|
||||
);
|
||||
expect(queryByTestId('workflow-review-activity-closed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Losing the note must not cost the actor and the verb too, which are known from the type.
|
||||
it('keeps a change request whose note can no longer be read', async () => {
|
||||
store.entries = [{ ...systemEntry, type: 'review.changes_requested', data: null }];
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('workflow-review-activity-changes-requested')).toHaveTextContent(
|
||||
'Requested changes',
|
||||
);
|
||||
expect(queryByTestId('workflow-review-activity-note')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-activity-unknown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No component is registered for this type yet, which is why the registry stays partial.
|
||||
it('shows a placeholder for an entry type no renderer covers yet', async () => {
|
||||
store.entries = [{ ...systemEntry, type: 'workflow.published', data: null }];
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('workflow-review-activity-unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the reviewer who acted', async () => {
|
||||
store.entries = [
|
||||
{
|
||||
...systemEntry,
|
||||
type: 'review.approved',
|
||||
data: {
|
||||
workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
|
||||
note: null,
|
||||
},
|
||||
createdBy: {
|
||||
id: 'user-1',
|
||||
email: 'ada@example.com',
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('workflow-review-activity-actor')).toHaveTextContent('Ada Lovelace');
|
||||
});
|
||||
|
||||
// The actor is missing for two different reasons, and a deleted approver must not
|
||||
// read as a system event.
|
||||
it('names a deleted reviewer as the actor of their decision', async () => {
|
||||
store.entries = [
|
||||
{
|
||||
...systemEntry,
|
||||
type: 'review.approved',
|
||||
data: {
|
||||
workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
|
||||
note: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('workflow-review-activity-actor')).toHaveTextContent('Deleted user');
|
||||
});
|
||||
|
||||
it('shows no actor at all for a review the system closed', async () => {
|
||||
store.entries = [
|
||||
{ ...systemEntry, type: 'review.closed', data: { reason: 'workflow-moved' } },
|
||||
];
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(queryByTestId('workflow-review-activity-actor')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nEmptyState, N8nLoading, N8nText } from '@n8n/design-system';
|
||||
import { N8nButton, N8nLoading, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
|
@ -52,6 +52,12 @@ watch(
|
|||
watch(
|
||||
entries,
|
||||
(next, previous) => {
|
||||
// Good for this update only, and only while its element is still in the list: `loadMore`
|
||||
// bails out on a cursor it has already spent, leaving an anchor behind, and a refetch
|
||||
// drops the entries an older anchor points at.
|
||||
const anchor = prependAnchor?.element.isConnected === true ? prependAnchor : null;
|
||||
prependAnchor = null;
|
||||
|
||||
if (next.length === 0) return;
|
||||
if (!previous || previous.length === 0) {
|
||||
scrollToBottom();
|
||||
|
|
@ -59,11 +65,13 @@ watch(
|
|||
}
|
||||
if (next[0]?.id !== previous[0]?.id) {
|
||||
const container = scrollContainer.value;
|
||||
if (container && prependAnchor) {
|
||||
container.scrollTop +=
|
||||
prependAnchor.element.getBoundingClientRect().top - prependAnchor.top;
|
||||
// With no anchor the list was replaced rather than prepended to — a refetch keeps
|
||||
// only what is newer than the page it got — so the newest entry is what to show.
|
||||
if (!container || !anchor) {
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
prependAnchor = null;
|
||||
container.scrollTop += anchor.element.getBoundingClientRect().top - anchor.top;
|
||||
return;
|
||||
}
|
||||
if (next.at(-1)?.id !== previous.at(-1)?.id) scrollToBottom();
|
||||
|
|
@ -91,6 +99,9 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<div ref="scrollContainer" :class="$style.feed" data-test-id="workflow-review-activity-feed">
|
||||
<div v-if="$slots.header" :class="$style.header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<N8nLoading v-if="loading && entries.length === 0" :loading="true" :rows="3" />
|
||||
<div
|
||||
v-else-if="error && entries.length === 0"
|
||||
|
|
@ -109,12 +120,6 @@ onMounted(() => {
|
|||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div v-else-if="entries.length === 0" data-test-id="workflow-review-activity-empty">
|
||||
<N8nEmptyState
|
||||
:heading="i18n.baseText('workflowReviews.detail.activity.empty.heading')"
|
||||
:description="i18n.baseText('workflowReviews.detail.activity.empty.description')"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="initialScrollApplied && hasMore && !error"
|
||||
|
|
@ -138,6 +143,9 @@ onMounted(() => {
|
|||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<N8nText bold color="text-light" size="medium" :class="$style.header">{{
|
||||
i18n.baseText('workflowReviews.detail.tabs.activity')
|
||||
}}</N8nText>
|
||||
<div
|
||||
ref="list"
|
||||
role="list"
|
||||
|
|
@ -148,6 +156,7 @@ onMounted(() => {
|
|||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
role="listitem"
|
||||
:class="$style.item"
|
||||
data-test-id="workflow-review-activity-entry"
|
||||
>
|
||||
<component :is="resolveActivityComponent(entry)" :entry="entry" />
|
||||
|
|
@ -164,7 +173,9 @@ onMounted(() => {
|
|||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-block: var(--spacing--2xs) var(--spacing--sm);
|
||||
padding-block: var(--spacing--5xs) var(--spacing--sm);
|
||||
/* Keeps the cards off the scrollbar that appears here when feed overflows */
|
||||
padding-inline-end: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
/* The detail body stacks and takes over scrolling here, so the feed must bound itself or its
|
||||
|
|
@ -175,10 +186,44 @@ onMounted(() => {
|
|||
}
|
||||
}
|
||||
|
||||
/* Same inset the list gives its entries, so a card here starts on the avatar column. */
|
||||
.header {
|
||||
padding-inline: var(--spacing--sm);
|
||||
padding-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.list {
|
||||
/* The rail below spans this gap, so both read it from here. */
|
||||
--review-activity--gap: var(--spacing--md);
|
||||
/* Every entry leads with an `xxsmall` avatar (`N8nAvatar/avatarSizes.ts`), and a boxed
|
||||
entry's negative margin and padding cancel out, so all avatars share this column. The
|
||||
rail below is centred on it. */
|
||||
--review-activity--avatar-size: 16px;
|
||||
--review-activity--line-height: 20px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
gap: var(--review-activity--gap);
|
||||
/* Entries sit inset; a boxed entry cancels this to reach the panel edge. */
|
||||
padding-inline: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Threads the entries into one timeline. Drawn in the gap above each entry rather than
|
||||
inside it, because the line belongs to the space between two entries and only the list
|
||||
knows that gap. The first entry has nothing above it to join. */
|
||||
.item:not(:first-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* Floats clear of both neighbours instead of butting into them, as the design does. The
|
||||
row is taller than its avatar, so the same inset reads as a wider gap at the top. */
|
||||
bottom: calc(100% + var(--spacing--5xs));
|
||||
height: calc(var(--review-activity--gap) - 2 * var(--spacing--5xs));
|
||||
left: calc(var(--review-activity--avatar-size) / 2);
|
||||
border-left: var(--border);
|
||||
}
|
||||
|
||||
.errorRow {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { WORKFLOW_REVIEW_COMMENT_MAX_LENGTH } from '@n8n/api-types';
|
||||
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';
|
||||
|
|
@ -29,7 +29,7 @@ describe('WorkflowReviewCommentComposer', () => {
|
|||
store = mockedStore(useReviewActivityStore);
|
||||
store.posting = false;
|
||||
store.draft = '';
|
||||
store.postComment.mockResolvedValue(undefined);
|
||||
store.postComment.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('keeps a half-typed comment when the composer is unmounted and shown again', async () => {
|
||||
|
|
@ -70,12 +70,12 @@ describe('WorkflowReviewCommentComposer', () => {
|
|||
const textarea = getByRole('textbox');
|
||||
|
||||
textarea.focus();
|
||||
await userEvent.paste('x'.repeat(WORKFLOW_REVIEW_COMMENT_MAX_LENGTH));
|
||||
await userEvent.paste('x'.repeat(WORKFLOW_REVIEW_TEXT_MAX_LENGTH));
|
||||
await waitFor(() => expect(getByTestId('send-message-button')).not.toBeDisabled());
|
||||
// Shift+Enter inserts the newline itself, so `maxlength` on the textarea does not stop it
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
|
||||
expect(textarea).toHaveValue(`${'x'.repeat(WORKFLOW_REVIEW_COMMENT_MAX_LENGTH)}\n`);
|
||||
expect(textarea).toHaveValue(`${'x'.repeat(WORKFLOW_REVIEW_TEXT_MAX_LENGTH)}\n`);
|
||||
await waitFor(() => expect(getByTestId('send-message-button')).toBeDisabled());
|
||||
});
|
||||
|
||||
|
|
@ -94,8 +94,8 @@ describe('WorkflowReviewCommentComposer', () => {
|
|||
let resolvePost!: () => void;
|
||||
store.postComment.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
resolvePost = resolve;
|
||||
await new Promise<boolean>((resolve) => {
|
||||
resolvePost = () => resolve(true);
|
||||
}),
|
||||
);
|
||||
const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
|
||||
|
|
@ -112,6 +112,27 @@ describe('WorkflowReviewCommentComposer', () => {
|
|||
expect(textarea).toHaveValue('Nice work and the next one');
|
||||
});
|
||||
|
||||
it('keeps the draft when the post it was waiting on belonged to another review', async () => {
|
||||
let resolvePost!: () => void;
|
||||
store.postComment.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<boolean>((resolve) => {
|
||||
// Stale: the viewer moved to another review while this was in flight.
|
||||
resolvePost = () => resolve(false);
|
||||
}),
|
||||
);
|
||||
const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
|
||||
const textarea = getByRole('textbox');
|
||||
|
||||
await userEvent.type(textarea, 'Nice work');
|
||||
await userEvent.click(getByTestId('send-message-button'));
|
||||
resolvePost();
|
||||
await new Promise(setImmediate);
|
||||
|
||||
// Identical text, so only the stale result can keep it from being cleared.
|
||||
expect(textarea).toHaveValue('Nice work');
|
||||
});
|
||||
|
||||
it('keeps the draft and surfaces an error when posting fails', async () => {
|
||||
store.postComment.mockRejectedValue(new Error('boom'));
|
||||
const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { WORKFLOW_REVIEW_COMMENT_MAX_LENGTH } from '@n8n/api-types';
|
||||
import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
|
||||
import { N8nChatInput } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
|
|
@ -21,7 +21,7 @@ const submitDisabled = computed(
|
|||
() =>
|
||||
posting.value ||
|
||||
draft.value.trim().length === 0 ||
|
||||
draft.value.length > WORKFLOW_REVIEW_COMMENT_MAX_LENGTH ||
|
||||
draft.value.length > WORKFLOW_REVIEW_TEXT_MAX_LENGTH ||
|
||||
!props.canComment,
|
||||
);
|
||||
|
||||
|
|
@ -31,9 +31,11 @@ async function onSubmit() {
|
|||
if (!body) return;
|
||||
|
||||
try {
|
||||
await store.postComment(body);
|
||||
// A stale post belongs to the review the viewer left, so its completion must not
|
||||
// touch the draft they are typing now, even if the two strings happen to match.
|
||||
const posted = await store.postComment(body);
|
||||
// Don't clear text typed while the post was in flight.
|
||||
if (draft.value === submitted) draft.value = '';
|
||||
if (posted && draft.value === submitted) draft.value = '';
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('workflowReviews.detail.activity.error.post'));
|
||||
}
|
||||
|
|
@ -49,7 +51,7 @@ async function onSubmit() {
|
|||
</span>
|
||||
<N8nChatInput
|
||||
v-model="draft"
|
||||
:max-length="WORKFLOW_REVIEW_COMMENT_MAX_LENGTH"
|
||||
:max-length="WORKFLOW_REVIEW_TEXT_MAX_LENGTH"
|
||||
:placeholder="i18n.baseText('workflowReviews.detail.activity.composer.placeholder')"
|
||||
refocus-after-send
|
||||
:disabled="!canComment"
|
||||
|
|
@ -65,10 +67,8 @@ async function onSubmit() {
|
|||
.composer {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
border-top: var(--border);
|
||||
/* The panel around this clips overflow, and the input draws its focus ring outside its own
|
||||
box, so without clearance on these three sides the ring is cut off. */
|
||||
padding: var(--spacing--sm) var(--spacing--3xs) var(--spacing--3xs);
|
||||
/* Prevent focus-ring clipping and vertically align with feed comments */
|
||||
padding: var(--spacing--lg) var(--spacing--2xs) var(--spacing--5xs) var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||
|
||||
import { useReviewActivityStore } from '../reviewActivity.store';
|
||||
import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue';
|
||||
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Reka UI popovers do not open in jsdom, so the four primitives N8nPopover imports are
|
||||
* stubbed to render inline. Copied rather than imported: the design system keeps its own
|
||||
* set private. Reka's trigger machinery goes with them, hence the open button standing in
|
||||
* for it — everything after that is the component's own `v-model:open`.
|
||||
*/
|
||||
const rekaStubs = {
|
||||
PopoverRoot: {
|
||||
props: ['open'],
|
||||
emits: ['update:open'],
|
||||
template: `
|
||||
<div :data-open="open" data-test-id="popover-root">
|
||||
<button data-test-id="open-popover" @click="$emit('update:open', true)" />
|
||||
<slot />
|
||||
</div>`,
|
||||
},
|
||||
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||
PopoverPortal: { template: '<div><slot /></div>' },
|
||||
PopoverContent: { template: '<div><slot /></div>' },
|
||||
};
|
||||
|
||||
// The real tooltip renders its content in a portal on hover, which jsdom cannot exercise;
|
||||
// expose the bindings as attributes instead.
|
||||
const tooltipStub = {
|
||||
N8nTooltip: {
|
||||
props: ['disabled', 'content'],
|
||||
template:
|
||||
'<div data-test-id="decision-tooltip" :data-disabled="disabled" :data-content="content"><slot /></div>',
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowReviewDecisionPopover, {
|
||||
global: { stubs: { ...rekaStubs, ...tooltipStub } },
|
||||
props: {
|
||||
deciding: false,
|
||||
viewerCanDecide: true,
|
||||
viewerCanComment: true,
|
||||
ineligibilityHint: '',
|
||||
},
|
||||
});
|
||||
|
||||
function tooltipOf(button: HTMLElement) {
|
||||
const tooltip = button.closest('[data-test-id="decision-tooltip"]');
|
||||
if (!tooltip) throw new Error('button is not wrapped in a tooltip');
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
const showError = vi.fn();
|
||||
|
||||
describe('WorkflowReviewDecisionPopover', () => {
|
||||
let store: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
showError.mockReset();
|
||||
vi.mocked(useToast).mockReturnValue({ showError } as unknown as ReturnType<typeof useToast>);
|
||||
store = mockedStore(useReviewActivityStore);
|
||||
store.decisionNote = '';
|
||||
store.posting = false;
|
||||
store.postComment.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('needs a note before the reviewer can request changes or comment', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
const requestChanges = getByTestId('workflow-review-decision-request-changes-button');
|
||||
expect(requestChanges).toBeDisabled();
|
||||
expect(getByTestId('workflow-review-decision-comment-button')).toBeDisabled();
|
||||
expect(tooltipOf(requestChanges)).toHaveAttribute(
|
||||
'data-content',
|
||||
'Add a note to request changes',
|
||||
);
|
||||
expect(tooltipOf(requestChanges)).toHaveAttribute('data-disabled', 'false');
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Please add retries');
|
||||
|
||||
expect(requestChanges).not.toBeDisabled();
|
||||
expect(getByTestId('workflow-review-decision-comment-button')).not.toBeDisabled();
|
||||
expect(tooltipOf(requestChanges)).toHaveAttribute('data-disabled', 'true');
|
||||
});
|
||||
|
||||
it.each<[string, { viewerCanComment?: boolean; deciding?: boolean }, boolean]>([
|
||||
['a viewer who is not allowed to comment', { viewerCanComment: false }, false],
|
||||
['a comment that is already being posted', {}, true],
|
||||
['a reviewer whose decision is in flight', { deciding: true }, false],
|
||||
])('offers no Comment button to %s, note or not', async (_label, props, posting) => {
|
||||
store.posting = posting;
|
||||
|
||||
const { getByTestId } = renderComponent({ props });
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
||||
|
||||
expect(getByTestId('workflow-review-decision-comment-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
// Posting a comment leaves its text in the box on purpose, so a decision taken meanwhile
|
||||
// would send the very same text a second time as its note.
|
||||
it.each<[string, { posting: boolean; deciding: boolean }]>([
|
||||
['a comment is still being posted', { posting: true, deciding: false }],
|
||||
['a decision is already in flight', { posting: false, deciding: true }],
|
||||
])('offers no decision while %s', async (_label, { posting, deciding }) => {
|
||||
store.posting = posting;
|
||||
|
||||
const { getByTestId } = renderComponent({ props: { deciding } });
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Please add retries');
|
||||
|
||||
expect(getByTestId('workflow-review-decision-request-changes-button')).toBeDisabled();
|
||||
expect(getByTestId('workflow-review-decision-approve-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('submits a change request with the note', async () => {
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Please add retries');
|
||||
await userEvent.click(getByTestId('workflow-review-decision-request-changes-button'));
|
||||
|
||||
expect(emitted('decide')).toEqual([
|
||||
[{ decision: 'changes_requested', note: 'Please add retries' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('submits an approval with the note', async () => {
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Ship it');
|
||||
await userEvent.click(getByTestId('workflow-review-decision-approve-button'));
|
||||
|
||||
expect(emitted('decide')).toEqual([[{ decision: 'approved', note: 'Ship it' }]]);
|
||||
});
|
||||
|
||||
it('approves without a note rather than sending an empty one', async () => {
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('workflow-review-decision-approve-button'));
|
||||
|
||||
expect(emitted('decide')).toEqual([[{ decision: 'approved' }]]);
|
||||
});
|
||||
|
||||
it('clears its open state once a decision is submitted', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('open-popover'));
|
||||
expect(getByTestId('popover-root')).toHaveAttribute('data-open', 'true');
|
||||
|
||||
await userEvent.click(getByTestId('workflow-review-decision-approve-button'));
|
||||
|
||||
expect(getByTestId('popover-root')).toHaveAttribute('data-open', 'false');
|
||||
});
|
||||
|
||||
it('posts the note as a comment and shows the viewer where it landed', async () => {
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
||||
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
||||
|
||||
expect(store.postComment).toHaveBeenCalledWith('Just a thought');
|
||||
expect(emitted('comment-posted')).toHaveLength(1);
|
||||
expect(store.clearDecisionNote).toHaveBeenCalledWith('Just a thought');
|
||||
expect(emitted('decide')).toBeUndefined();
|
||||
});
|
||||
|
||||
// The note is only dropped after the post comes back, by which time the viewer may be
|
||||
// typing the next one. Whether that text is theirs to drop is the store's rule.
|
||||
it('drops the note it posted, not whatever the viewer typed meanwhile', async () => {
|
||||
let resolvePost!: (landed: boolean) => void;
|
||||
store.postComment.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePost = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
||||
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
||||
|
||||
store.decisionNote = 'and one more thing';
|
||||
resolvePost(true);
|
||||
await waitAllPromises();
|
||||
|
||||
expect(store.clearDecisionNote).toHaveBeenCalledWith('Just a thought');
|
||||
expect(emitted('comment-posted')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The comment is written either way, but it says nothing about the review now on screen.
|
||||
it('leaves the review the viewer moved to alone when the comment lands late', async () => {
|
||||
store.postComment.mockResolvedValue(false);
|
||||
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
||||
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
||||
|
||||
expect(store.clearDecisionNote).not.toHaveBeenCalled();
|
||||
expect(emitted('comment-posted')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the note when the comment could not be posted', async () => {
|
||||
const error = new Error('nope');
|
||||
store.postComment.mockRejectedValueOnce(error);
|
||||
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Just a thought');
|
||||
await userEvent.click(getByTestId('workflow-review-decision-comment-button'));
|
||||
|
||||
expect(showError).toHaveBeenCalledWith(error, 'Could not post comment');
|
||||
expect(store.clearDecisionNote).not.toHaveBeenCalled();
|
||||
expect(getByTestId('workflow-review-decision-note')).toHaveValue('Just a thought');
|
||||
expect(emitted('comment-posted')).toBeUndefined();
|
||||
});
|
||||
|
||||
// The draft has to outlive both the popover closing and a switch to another review,
|
||||
// so the input is bound to the store rather than to a local ref.
|
||||
it('shows the note the review already has and writes edits back', async () => {
|
||||
store.decisionNote = 'started earlier';
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('workflow-review-decision-note')).toHaveValue('started earlier');
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), ' and continued');
|
||||
|
||||
expect(store.decisionNote).toBe('started earlier and continued');
|
||||
});
|
||||
|
||||
it('says why the viewer cannot decide instead of just blocking them', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
viewerCanDecide: false,
|
||||
ineligibilityHint: 'You contributed a version to this review.',
|
||||
},
|
||||
});
|
||||
|
||||
const trigger = getByTestId('workflow-review-decision-trigger');
|
||||
expect(trigger).toBeDisabled();
|
||||
expect(tooltipOf(trigger)).toHaveAttribute(
|
||||
'data-content',
|
||||
'You contributed a version to this review.',
|
||||
);
|
||||
});
|
||||
|
||||
// A wrapper around the trigger takes the popover's own trigger props with it: the click,
|
||||
// the expanded state a screen reader announces, and focus coming back on close.
|
||||
it('hands a reviewer who may decide the trigger itself rather than a wrapper', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(
|
||||
getByTestId('workflow-review-decision-trigger').closest('[data-test-id="decision-tooltip"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the reviewer out while their decision is in flight', () => {
|
||||
const { getByTestId } = renderComponent({ props: { deciding: true } });
|
||||
|
||||
expect(getByTestId('workflow-review-decision-trigger')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
<script setup lang="ts">
|
||||
import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { N8nButton, N8nIcon, N8nInput, N8nPopover, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, ref, useId } from 'vue';
|
||||
|
||||
import { useReviewActivityStore } from '../reviewActivity.store';
|
||||
import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
|
||||
|
||||
const props = defineProps<{
|
||||
deciding: boolean;
|
||||
viewerCanDecide: boolean;
|
||||
viewerCanComment: boolean;
|
||||
/** Why the viewer may not decide; empty when they may. */
|
||||
ineligibilityHint: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
decide: [input: WorkflowReviewDecisionInput];
|
||||
'comment-posted': [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { showError } = useToast();
|
||||
const store = useReviewActivityStore();
|
||||
const { posting, decisionNote } = storeToRefs(store);
|
||||
|
||||
const isOpen = ref(false);
|
||||
const noteInputId = useId();
|
||||
|
||||
const note = computed(() => decisionNote.value.trim());
|
||||
|
||||
// Every viewer who may decide may also comment, and a viewer who may not decide cannot open
|
||||
// the popover at all, so the last term only guards against those two rules drifting apart.
|
||||
const commentDisabled = computed(
|
||||
() => posting.value || props.deciding || note.value.length === 0 || !props.viewerCanComment,
|
||||
);
|
||||
|
||||
// A comment in flight leaves its text in the box on purpose, so without this the same text
|
||||
// goes out twice: once as the comment, once as the decision note.
|
||||
const decisionDisabled = computed(() => props.deciding || posting.value);
|
||||
|
||||
function submitDecision(input: WorkflowReviewDecisionInput) {
|
||||
isOpen.value = false;
|
||||
emit('decide', input);
|
||||
}
|
||||
|
||||
function onRequestChanges() {
|
||||
submitDecision({ decision: 'changes_requested', note: note.value });
|
||||
}
|
||||
|
||||
function onApprove() {
|
||||
// The key is omitted rather than sent empty: the DTO rejects `''`.
|
||||
submitDecision(
|
||||
note.value ? { decision: 'approved', note: note.value } : { decision: 'approved' },
|
||||
);
|
||||
}
|
||||
|
||||
async function onComment() {
|
||||
const body = note.value;
|
||||
|
||||
isOpen.value = false;
|
||||
try {
|
||||
// A comment that landed after the viewer moved on says nothing about the review they
|
||||
// are reading now, so neither its note nor its tab may be touched.
|
||||
if (!(await store.postComment(body))) return;
|
||||
|
||||
store.clearDecisionNote(body);
|
||||
// The trigger sits outside the tab panel, so a comment posted from the Changes tab
|
||||
// would otherwise land nowhere the viewer can see.
|
||||
emit('comment-posted');
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('workflowReviews.detail.activity.error.post'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 480px deviates from Figma's 440px on purpose: the three icon+label buttons do not fit
|
||||
in 440px, and N8nPopover sets a fixed width, so the row would spill rather than wrap. -->
|
||||
<N8nPopover
|
||||
v-model:open="isOpen"
|
||||
side="bottom"
|
||||
align="end"
|
||||
width="480px"
|
||||
:enable-scrolling="false"
|
||||
:content-class="$style.popover"
|
||||
>
|
||||
<template #trigger>
|
||||
<!-- Wrapped only while the viewer may not decide: N8nTooltip does not forward attrs
|
||||
on this branch, so the trigger props land on the span — which is what makes the
|
||||
hint fire over a natively disabled button, and this trigger cannot open anything
|
||||
anyway. An enabled trigger has to stay the bare button, or it loses `aria-expanded`
|
||||
and the focus it gets back on close. -->
|
||||
<span v-if="ineligibilityHint">
|
||||
<N8nTooltip :content="ineligibilityHint" :show-after="300">
|
||||
<N8nButton
|
||||
size="small"
|
||||
:disabled="deciding || !viewerCanDecide"
|
||||
data-test-id="workflow-review-decision-trigger"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.decision.trigger') }}
|
||||
<N8nIcon icon="chevron-down" />
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</span>
|
||||
<N8nButton
|
||||
v-else
|
||||
size="small"
|
||||
:disabled="deciding || !viewerCanDecide"
|
||||
data-test-id="workflow-review-decision-trigger"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.decision.trigger') }}
|
||||
<N8nIcon icon="chevron-down" />
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content" data-test-id="workflow-review-decision-popover">
|
||||
<N8nInput
|
||||
:id="noteInputId"
|
||||
:aria-label="i18n.baseText('workflowReviews.detail.decision.note.label')"
|
||||
v-model="decisionNote"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:maxlength="WORKFLOW_REVIEW_TEXT_MAX_LENGTH"
|
||||
:placeholder="i18n.baseText('workflowReviews.detail.activity.composer.placeholder')"
|
||||
data-test-id="workflow-review-decision-note"
|
||||
/>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
:disabled="commentDisabled"
|
||||
data-test-id="workflow-review-decision-comment-button"
|
||||
@click="onComment"
|
||||
>
|
||||
<template #icon>
|
||||
<N8nIcon icon="message-square" />
|
||||
</template>
|
||||
{{ i18n.baseText('workflowReviews.detail.decision.comment') }}
|
||||
</N8nButton>
|
||||
<N8nTooltip
|
||||
:disabled="!!note"
|
||||
:content="i18n.baseText('workflowReviews.detail.decision.note.required')"
|
||||
:show-after="300"
|
||||
>
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
:disabled="!note || decisionDisabled"
|
||||
data-test-id="workflow-review-decision-request-changes-button"
|
||||
@click="onRequestChanges"
|
||||
>
|
||||
<template #icon>
|
||||
<N8nIcon icon="refresh-cw" />
|
||||
</template>
|
||||
{{ i18n.baseText('workflowReviews.detail.decision.requestChanges') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
:disabled="decisionDisabled"
|
||||
data-test-id="workflow-review-decision-approve-button"
|
||||
@click="onApprove"
|
||||
>
|
||||
<template #icon>
|
||||
<N8nIcon icon="check" />
|
||||
</template>
|
||||
{{ i18n.baseText('workflowReviews.detail.decision.approveAndPublish') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.popover {
|
||||
padding: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -38,13 +38,13 @@ const statusSummary = computed(() => {
|
|||
<aside :class="$style.metadata" data-test-id="workflow-review-detail-metadata">
|
||||
<N8nCard :class="$style.card" data-test-id="workflow-review-detail-status-card">
|
||||
<template #header>
|
||||
<N8nText bold color="text-light" size="small">
|
||||
<N8nText bold color="text-light" size="medium">
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.status') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<div :class="$style.status">
|
||||
<WorkflowReviewStatusDot :state="review.state" :decision="review.decision" />
|
||||
<N8nText size="small">{{ statusSummary }}</N8nText>
|
||||
<N8nText size="medium">{{ statusSummary }}</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ const statusSummary = computed(() => {
|
|||
data-test-id="workflow-review-detail-people-card"
|
||||
>
|
||||
<div :class="$style.section">
|
||||
<N8nText bold color="text-light" size="small">
|
||||
<N8nText bold color="text-light" size="medium">
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.requestedBy') }}
|
||||
</N8nText>
|
||||
<div v-if="review.requester" :class="$style.person">
|
||||
|
|
@ -62,12 +62,12 @@ const statusSummary = computed(() => {
|
|||
:last-name="review.requester.lastName"
|
||||
size="xsmall"
|
||||
/>
|
||||
<N8nText size="small">{{ formatUserDisplayName(review.requester) }}</N8nText>
|
||||
<N8nText size="medium">{{ formatUserDisplayName(review.requester) }}</N8nText>
|
||||
</div>
|
||||
<N8nText
|
||||
v-else
|
||||
color="text-light"
|
||||
size="small"
|
||||
size="medium"
|
||||
data-test-id="workflow-review-detail-requester-deleted"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.requesterDeleted') }}
|
||||
|
|
@ -79,17 +79,17 @@ const statusSummary = computed(() => {
|
|||
:class="$style.section"
|
||||
data-test-id="workflow-review-detail-other-authors"
|
||||
>
|
||||
<N8nText bold color="text-light" size="small">
|
||||
<N8nText bold color="text-light" size="medium">
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.otherAuthors') }}
|
||||
</N8nText>
|
||||
<div v-for="author in otherAuthors" :key="author.id" :class="$style.person">
|
||||
<N8nAvatar :first-name="author.firstName" :last-name="author.lastName" size="xsmall" />
|
||||
<N8nText size="small">{{ formatUserDisplayName(author) }}</N8nText>
|
||||
<N8nText size="medium">{{ formatUserDisplayName(author) }}</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.section">
|
||||
<N8nText bold color="text-light" size="small">
|
||||
<N8nText bold color="text-light" size="medium">
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.reviewers') }}
|
||||
</N8nText>
|
||||
<div v-for="reviewer in review.reviewers" :key="reviewer.id" :class="$style.person">
|
||||
|
|
@ -98,12 +98,12 @@ const statusSummary = computed(() => {
|
|||
:last-name="reviewer.lastName"
|
||||
size="xsmall"
|
||||
/>
|
||||
<N8nText size="small">{{ formatUserDisplayName(reviewer) }}</N8nText>
|
||||
<N8nText size="medium">{{ formatUserDisplayName(reviewer) }}</N8nText>
|
||||
</div>
|
||||
<N8nText
|
||||
v-if="review.reviewers.length === 0"
|
||||
color="text-light"
|
||||
size="small"
|
||||
size="medium"
|
||||
data-test-id="workflow-review-detail-no-reviewers"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.noReviewers') }}
|
||||
|
|
@ -117,7 +117,7 @@ const statusSummary = computed(() => {
|
|||
data-test-id="workflow-review-detail-changes-card"
|
||||
>
|
||||
<template #header>
|
||||
<N8nText bold color="text-light" size="small">
|
||||
<N8nText bold color="text-light" size="medium">
|
||||
{{ i18n.baseText('workflowReviews.detail.metadata.workflow') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
|
@ -127,11 +127,11 @@ const statusSummary = computed(() => {
|
|||
:key="workflow.workflowId"
|
||||
:to="{ name: VIEWS.WORKFLOW, params: { workflowId: workflow.workflowId } }"
|
||||
theme="text"
|
||||
size="small"
|
||||
size="medium"
|
||||
:class="$style.workflow"
|
||||
data-test-id="workflow-review-detail-workflow-link"
|
||||
>
|
||||
<N8nIcon icon="workflow" size="small" :class="$style.workflowIcon" />
|
||||
<N8nIcon icon="workflow" size="medium" :class="$style.workflowIcon" />
|
||||
<span :class="$style.workflowName">{{ workflow.workflowName }}</span>
|
||||
</N8nLink>
|
||||
</div>
|
||||
|
|
@ -146,6 +146,7 @@ const statusSummary = computed(() => {
|
|||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 14rem;
|
||||
padding-top: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.card {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import type { WorkflowReviewRequestDetail } from '@n8n/api-types';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
import { useReviewActivityStore } from '../reviewActivity.store';
|
||||
import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
|
||||
|
||||
/**
|
||||
* The real decision popover is kept, so this pins the two facts the draft note depends on:
|
||||
* it lives in the store, and its trigger sits outside the tab panel that unmounts.
|
||||
* Reka UI popovers do not open in jsdom, so its primitives render inline instead.
|
||||
*/
|
||||
const rekaStubs = {
|
||||
PopoverRoot: { template: '<div><slot /></div>' },
|
||||
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||
PopoverPortal: { template: '<div><slot /></div>' },
|
||||
PopoverContent: { template: '<div><slot /></div>' },
|
||||
};
|
||||
|
||||
const childStubs = {
|
||||
WorkflowReviewActivityFeed: { template: '<div><slot name="header" /></div>' },
|
||||
WorkflowReviewChangesSection: { template: '<div />' },
|
||||
WorkflowReviewCommentComposer: { template: '<div />' },
|
||||
WorkflowReviewDetailMetadata: { template: '<aside />' },
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowReviewDetailTabs, {
|
||||
global: { stubs: { ...rekaStubs, ...childStubs } },
|
||||
});
|
||||
|
||||
const review: WorkflowReviewRequestDetail = {
|
||||
id: 'req-1',
|
||||
projectId: 'proj-1',
|
||||
title: 'Needs 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',
|
||||
description: null,
|
||||
workflows: [],
|
||||
viewerCanDecide: true,
|
||||
viewerDecisionIneligibilityReason: null,
|
||||
viewerCanComment: true,
|
||||
};
|
||||
|
||||
describe('WorkflowReviewDetailTabs decision note', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
});
|
||||
|
||||
it('keeps a half-written decision note across a tab switch', async () => {
|
||||
const { getByTestId, rerender } = renderComponent({
|
||||
props: { review, tab: 'activity', deciding: false },
|
||||
});
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Half a thought');
|
||||
|
||||
await rerender({ tab: 'changes' });
|
||||
await rerender({ tab: 'activity' });
|
||||
|
||||
expect(getByTestId('workflow-review-decision-note')).toHaveValue('Half a thought');
|
||||
});
|
||||
|
||||
// The two facts that make the note survive: it is typed into the store, and the popover
|
||||
// sits outside the panel the tab switch unmounts. Asserted separately because either one
|
||||
// alone still reads back the note above.
|
||||
it('holds the note in the store rather than in the popover', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { review, tab: 'activity', deciding: false },
|
||||
});
|
||||
|
||||
await userEvent.type(getByTestId('workflow-review-decision-note'), 'Half a thought');
|
||||
|
||||
expect(useReviewActivityStore().decisionNote).toBe('Half a thought');
|
||||
});
|
||||
|
||||
it('keeps the decision popover out of the tab panel', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { review, tab: 'activity', deciding: false },
|
||||
});
|
||||
|
||||
expect(getByTestId('workflow-review-activity-panel')).not.toContainElement(
|
||||
getByTestId('workflow-review-decision-note'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ vi.mock('./WorkflowReviewChangesSection.vue', () => ({
|
|||
vi.mock('./WorkflowReviewActivityFeed.vue', () => ({
|
||||
default: {
|
||||
name: 'WorkflowReviewActivityFeed',
|
||||
template: '<div data-test-id="workflow-review-activity-feed" />',
|
||||
template: '<div data-test-id="workflow-review-activity-feed"><slot name="header" /></div>',
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -39,36 +39,24 @@ vi.mock('./WorkflowReviewDetailMetadata.vue', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowReviewDetailTabs, {
|
||||
global: {
|
||||
stubs: {
|
||||
N8nTooltip: {
|
||||
props: ['disabled', 'content'],
|
||||
template: `
|
||||
<div data-test-id="workflow-review-decision-tooltip" :data-disabled="disabled" :data-content="content">
|
||||
<slot />
|
||||
</div>`,
|
||||
},
|
||||
},
|
||||
// The real popover cannot open in jsdom (Reka UI), so expose what this component passes
|
||||
// down as attributes and let a button stand in for the comment it reports back.
|
||||
vi.mock('./WorkflowReviewDecisionPopover.vue', () => ({
|
||||
default: {
|
||||
name: 'WorkflowReviewDecisionPopover',
|
||||
props: ['deciding', 'viewerCanDecide', 'viewerCanComment', 'ineligibilityHint'],
|
||||
template: `
|
||||
<div
|
||||
data-test-id="workflow-review-decision-popover"
|
||||
:data-can-decide="viewerCanDecide"
|
||||
:data-ineligibility-hint="ineligibilityHint"
|
||||
>
|
||||
<button data-test-id="emit-comment-posted" @click="$emit('comment-posted')" />
|
||||
</div>`,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
function decisionButtons(getByTestId: (id: string) => HTMLElement) {
|
||||
return [
|
||||
getByTestId('workflow-review-approve-button'),
|
||||
getByTestId('workflow-review-request-changes-button'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab bar renders a tooltip per tab, which the N8nTooltip stub matches too,
|
||||
* so walk up from the button rather than querying the test id globally.
|
||||
*/
|
||||
function decisionTooltip(button: HTMLElement) {
|
||||
const tooltip = button.closest('[data-test-id="workflow-review-decision-tooltip"]');
|
||||
if (!tooltip) throw new Error('decision button is not wrapped in a tooltip');
|
||||
return tooltip;
|
||||
}
|
||||
const renderComponent = createComponentRenderer(WorkflowReviewDetailTabs);
|
||||
|
||||
function makeInboxItem(overrides: Partial<WorkflowReviewInboxItem> = {}): WorkflowReviewInboxItem {
|
||||
return {
|
||||
|
|
@ -152,7 +140,9 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
expect(getByTestId('workflow-review-no-description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the feed and the composer below the description', () => {
|
||||
// The description has to sit inside the feed's scroll container for the two to scroll
|
||||
// together, and the composer has to stay outside it to keep its place at the bottom.
|
||||
it('scrolls the description with the feed and keeps the composer below both', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
review: makeDetail({ description: 'Adds retry logic' }),
|
||||
|
|
@ -161,19 +151,12 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const panel = getByTestId('workflow-review-activity-panel');
|
||||
const order = [
|
||||
'workflow-review-description',
|
||||
'workflow-review-activity-feed',
|
||||
'workflow-review-comment-composer',
|
||||
].map((testId) => {
|
||||
const element = panel.querySelector(`[data-test-id="${testId}"]`);
|
||||
if (!element) throw new Error(`${testId} is not in the activity panel`);
|
||||
return element;
|
||||
});
|
||||
const feed = getByTestId('workflow-review-activity-feed');
|
||||
const description = getByTestId('workflow-review-description');
|
||||
const composer = getByTestId('workflow-review-comment-composer');
|
||||
|
||||
expect(order[0].compareDocumentPosition(order[1])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(order[1].compareDocumentPosition(order[2])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(feed).toContainElement(description);
|
||||
expect(feed.compareDocumentPosition(composer)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -272,18 +255,19 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
});
|
||||
|
||||
describe('decision actions', () => {
|
||||
it('emits decisions from the action buttons', () => {
|
||||
// The trigger lives outside the tab panel, so a comment posted from the Changes tab
|
||||
// would otherwise succeed with nothing to show for it.
|
||||
it('switches to the activity tab once a comment was posted from the popover', () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { review: makeDetail(), tab: 'activity', deciding: false },
|
||||
props: { review: makeDetail(), tab: 'changes', deciding: false },
|
||||
});
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('workflow-review-request-changes-button').click();
|
||||
getByTestId('emit-comment-posted').click();
|
||||
|
||||
expect(emitted('decide')).toEqual([['approved'], ['changes_requested']]);
|
||||
expect(emitted('update:tab')).toEqual([['activity']]);
|
||||
});
|
||||
|
||||
it('hides the action buttons for a closed review', () => {
|
||||
it('offers no decision on a closed review', () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
review: makeDetail({ state: 'closed', decision: 'approved' }),
|
||||
|
|
@ -292,41 +276,28 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('workflow-review-approve-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-request-changes-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the action buttons on a review whose detail payload never loaded', () => {
|
||||
it('offers no decision on a review whose detail payload never loaded', () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: { review: makeInboxItem(), tab: 'activity', deciding: false },
|
||||
});
|
||||
|
||||
expect(queryByTestId('workflow-review-approve-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-request-changes-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the action buttons while deciding', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { review: makeDetail(), tab: 'activity', deciding: true },
|
||||
});
|
||||
|
||||
expect(getByTestId('workflow-review-approve-button')).toBeDisabled();
|
||||
expect(getByTestId('workflow-review-request-changes-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
// Each button carries its own tooltip, so both must agree.
|
||||
it('keeps the buttons enabled and the tooltips off when the viewer can decide', () => {
|
||||
it('leaves the decision actions open with no hint when the viewer can decide', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { review: makeDetail(), tab: 'activity', deciding: false },
|
||||
});
|
||||
|
||||
for (const button of decisionButtons(getByTestId)) {
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(decisionTooltip(button)).toHaveAttribute('data-disabled', 'true');
|
||||
}
|
||||
const popover = getByTestId('workflow-review-decision-popover');
|
||||
expect(popover).toHaveAttribute('data-can-decide', 'true');
|
||||
expect(popover).toHaveAttribute('data-ineligibility-hint', '');
|
||||
});
|
||||
|
||||
it('disables the buttons and says why when the viewer contributed a version', () => {
|
||||
it('says the viewer contributed a version when that is why they cannot decide', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
review: makeDetail({
|
||||
|
|
@ -338,14 +309,12 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
},
|
||||
});
|
||||
|
||||
for (const button of decisionButtons(getByTestId)) {
|
||||
expect(button).toBeDisabled();
|
||||
expect(decisionTooltip(button)).toHaveAttribute('data-disabled', 'false');
|
||||
expect(decisionTooltip(button)).toHaveAttribute(
|
||||
'data-content',
|
||||
'You contributed a version to this review.',
|
||||
);
|
||||
}
|
||||
const popover = getByTestId('workflow-review-decision-popover');
|
||||
expect(popover).toHaveAttribute('data-can-decide', 'false');
|
||||
expect(popover).toHaveAttribute(
|
||||
'data-ineligibility-hint',
|
||||
'You contributed a version to this review.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic permission hint for any other reason', () => {
|
||||
|
|
@ -360,30 +329,12 @@ describe('WorkflowReviewDetailTabs', () => {
|
|||
},
|
||||
});
|
||||
|
||||
for (const button of decisionButtons(getByTestId)) {
|
||||
expect(button).toBeDisabled();
|
||||
expect(decisionTooltip(button)).toHaveAttribute(
|
||||
'data-content',
|
||||
'Missing permissions to perform this action',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not emit a decision for an ineligible viewer', () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
review: makeDetail({
|
||||
viewerCanDecide: false,
|
||||
viewerDecisionIneligibilityReason: 'author',
|
||||
}),
|
||||
tab: 'activity',
|
||||
deciding: false,
|
||||
},
|
||||
});
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
|
||||
expect(emitted('decide')).toBeUndefined();
|
||||
const popover = getByTestId('workflow-review-decision-popover');
|
||||
expect(popover).toHaveAttribute('data-can-decide', 'false');
|
||||
expect(popover).toHaveAttribute(
|
||||
'data-ineligibility-hint',
|
||||
'Missing permissions to perform this action',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { WorkflowReviewInboxItem, WorkflowReviewRequestDetail } from '@n8n/api-types';
|
||||
import { N8nButton, N8nCallout, N8nTabs, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { N8nCallout, N8nTabs, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
|
|||
import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
|
||||
import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
|
||||
import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
|
||||
import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue';
|
||||
import WorkflowReviewDetailMetadata from './WorkflowReviewDetailMetadata.vue';
|
||||
|
||||
export type WorkflowReviewDetailTab = 'activity' | 'changes';
|
||||
|
|
@ -20,7 +21,7 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
'update:tab': [tab: WorkflowReviewDetailTab];
|
||||
decide: [decision: WorkflowReviewDecisionInput];
|
||||
decide: [input: WorkflowReviewDecisionInput];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -67,27 +68,14 @@ const tabOptions = computed(() => [
|
|||
<!-- Gated on `detail`, not `review`: eligibility only arrives with the
|
||||
detail payload, so the list item alone can't say who may decide. -->
|
||||
<div v-if="detail?.state === 'open'" :class="$style.decisionActions">
|
||||
<N8nTooltip :disabled="!ineligibilityHint" :content="ineligibilityHint" :show-after="300">
|
||||
<N8nButton
|
||||
size="small"
|
||||
:disabled="deciding || !viewerCanDecide"
|
||||
data-test-id="workflow-review-approve-button"
|
||||
@click="emit('decide', 'approved')"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.decision.approve') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip :disabled="!ineligibilityHint" :content="ineligibilityHint" :show-after="300">
|
||||
<N8nButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="deciding || !viewerCanDecide"
|
||||
data-test-id="workflow-review-request-changes-button"
|
||||
@click="emit('decide', 'changes_requested')"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.decision.requestChanges') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<WorkflowReviewDecisionPopover
|
||||
:deciding="deciding"
|
||||
:viewer-can-decide="viewerCanDecide"
|
||||
:viewer-can-comment="viewerCanComment"
|
||||
:ineligibility-hint="ineligibilityHint"
|
||||
@decide="emit('decide', $event)"
|
||||
@comment-posted="emit('update:tab', 'activity')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -97,27 +85,35 @@ const tabOptions = computed(() => [
|
|||
:class="$style.activityPanel"
|
||||
data-test-id="workflow-review-activity-panel"
|
||||
>
|
||||
<div :class="$style.activityHeader">
|
||||
<N8nText
|
||||
v-if="detail?.description"
|
||||
color="text-base"
|
||||
size="medium"
|
||||
:class="$style.description"
|
||||
data-test-id="workflow-review-description"
|
||||
>
|
||||
{{ detail.description }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else
|
||||
color="text-light"
|
||||
size="medium"
|
||||
data-test-id="workflow-review-no-description"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.activity.noDescription') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<WorkflowReviewActivityFeed :key="review.id" />
|
||||
<WorkflowReviewActivityFeed :key="review.id">
|
||||
<template #header>
|
||||
<!-- Carded and labelled so the review's own words are not mistaken for the
|
||||
first entry of the feed below it. -->
|
||||
<div :class="$style.descriptionCard">
|
||||
<N8nText tag="h3" bold color="text-light" size="medium">
|
||||
{{ i18n.baseText('workflowReviews.detail.activity.description') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="detail?.description"
|
||||
color="text-base"
|
||||
size="medium"
|
||||
:class="$style.description"
|
||||
data-test-id="workflow-review-description"
|
||||
>
|
||||
{{ detail.description }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else
|
||||
color="text-light"
|
||||
size="medium"
|
||||
:class="$style.noDescription"
|
||||
data-test-id="workflow-review-no-description"
|
||||
>
|
||||
{{ i18n.baseText('workflowReviews.detail.activity.noDescription') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
</WorkflowReviewActivityFeed>
|
||||
|
||||
<WorkflowReviewCommentComposer :can-comment="viewerCanComment" />
|
||||
</div>
|
||||
|
|
@ -162,6 +158,8 @@ const tabOptions = computed(() => [
|
|||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@use './activity-card' as *;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -198,28 +196,34 @@ const tabOptions = computed(() => [
|
|||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
max-width: var(--review-activity--max-width, 48rem);
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
/* Capped so a long description cannot push the composer off screen. */
|
||||
.activityHeader {
|
||||
flex-shrink: 0;
|
||||
max-height: 30%;
|
||||
overflow: auto;
|
||||
padding-bottom: var(--spacing--sm);
|
||||
.descriptionCard {
|
||||
@include activity-card;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.callout {
|
||||
max-width: var(--review-callout--max-width, 34rem);
|
||||
}
|
||||
|
||||
/* `pre-wrap` alone does not break a pasted URL, which is the one thing that could scroll the
|
||||
card sideways. */
|
||||
.description {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.noDescription {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.decisionActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -237,6 +241,8 @@ const tabOptions = computed(() => [
|
|||
.activityPanel {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ function onListBackgroundClick() {
|
|||
min-width: 0;
|
||||
max-width: var(--review-sidebar--width);
|
||||
height: 100%;
|
||||
border-right: var(--border-width) solid var(--color--foreground--tint-1);
|
||||
border-right: var(--border-width) solid var(--border-color);
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
// Shared by the feed's entry cards and the description card above them.
|
||||
|
||||
/* The negative margin cancels the list's inset so every avatar sits in one column, boxed or
|
||||
not; the border comes out of the padding so carded text starts on that same column. */
|
||||
@mixin activity-card {
|
||||
margin-inline: calc(-1 * var(--spacing--sm));
|
||||
padding: var(--spacing--xs) calc(var(--spacing--sm) - var(--border-width, 1px));
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--2xs);
|
||||
}
|
||||
|
||||
@mixin activity-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
/* An avatar is shorter than a line of text, so the row's `flex-start` hangs it above the name.
|
||||
Half the leading puts it back on that line — and only that line, so it stays put when a note
|
||||
or a wrapped headline makes the row taller. Every entry kind must include this; without it
|
||||
the avatar reads as sitting too high. */
|
||||
@mixin activity-avatar {
|
||||
flex-shrink: 0;
|
||||
margin-block-start: calc(
|
||||
(var(--review-activity--line-height) - var(--review-activity--avatar-size)) / 2
|
||||
);
|
||||
}
|
||||
|
||||
/* The actor, the sentence and its timestamp read as one line, so every entry kind spaces
|
||||
them by a word's worth. */
|
||||
@mixin activity-headline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--4xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
|
||||
import { formatUserDisplayName } from '../../workflowReviews.utils';
|
||||
import { formatActorName } from '../../workflowReviews.utils';
|
||||
|
||||
defineProps<{
|
||||
entry: Extract<WorkflowReviewActivityEntry, { type: 'comment.created' }>;
|
||||
|
|
@ -14,9 +14,10 @@ defineProps<{
|
|||
const i18n = useI18n();
|
||||
|
||||
function authorName(message: WorkflowReviewActivityMessage): string {
|
||||
return message.createdBy
|
||||
? formatUserDisplayName(message.createdBy)
|
||||
: i18n.baseText('workflowReviews.detail.activity.unknownAuthor');
|
||||
return formatActorName(
|
||||
message.createdBy,
|
||||
i18n.baseText('workflowReviews.detail.activity.unknownAuthor'),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -24,7 +25,8 @@ function authorName(message: WorkflowReviewActivityMessage): string {
|
|||
<div :class="$style.entry">
|
||||
<div v-for="message in entry.messages" :key="message.id" :class="$style.message">
|
||||
<N8nAvatar
|
||||
size="small"
|
||||
size="xxsmall"
|
||||
:class="$style.avatar"
|
||||
:first-name="message.createdBy?.firstName"
|
||||
:last-name="message.createdBy?.lastName"
|
||||
/>
|
||||
|
|
@ -38,10 +40,11 @@ function authorName(message: WorkflowReviewActivityMessage): string {
|
|||
>
|
||||
{{ authorName(message) }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
<N8nText size="small" color="text-light">
|
||||
<time
|
||||
:datetime="message.createdAt"
|
||||
data-test-id="workflow-review-activity-comment-time"
|
||||
:class="$style.timeStamp"
|
||||
>
|
||||
<TimeAgo :date="message.createdAt" />
|
||||
</time>
|
||||
|
|
@ -71,16 +74,23 @@ function authorName(message: WorkflowReviewActivityMessage): string {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '../activity-card' as *;
|
||||
|
||||
/* A comment is prose, so it gets the same card as a decision that carries a note. */
|
||||
.entry {
|
||||
@include activity-card;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
@include activity-row;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@include activity-avatar;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
|
@ -91,14 +101,15 @@ function authorName(message: WorkflowReviewActivityMessage): string {
|
|||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--2xs);
|
||||
@include activity-headline;
|
||||
}
|
||||
|
||||
/* Figma asks for 20px on 14px text; no line-height token gives that ratio. */
|
||||
.line {
|
||||
line-height: 20px;
|
||||
line-height: var(--review-activity--line-height);
|
||||
}
|
||||
|
||||
.timeStamp {
|
||||
padding-left: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.body {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
<script setup lang="ts">
|
||||
import type { WorkflowReviewActivityEntry, WorkflowReviewClosedReason } from '@n8n/api-types';
|
||||
import { N8nAvatar, N8nText } from '@n8n/design-system';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
|
||||
import { formatActorName } from '../../workflowReviews.utils';
|
||||
import WorkflowReviewActivityFallback from './WorkflowReviewActivityFallback.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
entry: Extract<
|
||||
WorkflowReviewActivityEntry,
|
||||
{
|
||||
type:
|
||||
| 'review.opened'
|
||||
| 'review.changes_requested'
|
||||
| 'review.approved'
|
||||
| 'review.version_updated'
|
||||
| 'review.closed';
|
||||
}
|
||||
>;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const closedReasonKeys: Record<WorkflowReviewClosedReason, BaseTextKey> = {
|
||||
'workflow-archived': 'workflowReviews.detail.activity.closed.archived',
|
||||
'workflow-moved': 'workflowReviews.detail.activity.closed.moved',
|
||||
'workflow-deleted': 'workflowReviews.detail.activity.closed.deleted',
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything that varies by type, so a new type is one case rather than several. `null` only for
|
||||
* `review.closed`, whose sentence *is* the stored reason and so has nothing to fall back to. The
|
||||
* rest say what happened from the type alone, and an unreadable payload costs them only the note.
|
||||
*
|
||||
* `namesActor` follows the type, not a missing `createdBy`: that is also null for a decision
|
||||
* whose author was deleted, which must still read as that person's decision.
|
||||
*/
|
||||
const content = computed<{
|
||||
text: string;
|
||||
note: string | null;
|
||||
testId: string;
|
||||
namesActor: boolean;
|
||||
} | null>(() => {
|
||||
const entry = props.entry;
|
||||
switch (entry.type) {
|
||||
case 'review.opened':
|
||||
return {
|
||||
text: i18n.baseText('workflowReviews.detail.activity.opened'),
|
||||
note: null,
|
||||
testId: 'workflow-review-activity-opened',
|
||||
namesActor: true,
|
||||
};
|
||||
case 'review.changes_requested':
|
||||
return {
|
||||
text: i18n.baseText('workflowReviews.detail.activity.changesRequested'),
|
||||
note: entry.data?.note ?? null,
|
||||
testId: 'workflow-review-activity-changes-requested',
|
||||
namesActor: true,
|
||||
};
|
||||
case 'review.approved':
|
||||
return {
|
||||
text: i18n.baseText('workflowReviews.detail.activity.approved'),
|
||||
note: entry.data?.note ?? null,
|
||||
testId: 'workflow-review-activity-approved',
|
||||
namesActor: true,
|
||||
};
|
||||
case 'review.version_updated':
|
||||
return {
|
||||
text: i18n.baseText('workflowReviews.detail.activity.versionUpdated'),
|
||||
note: null,
|
||||
testId: 'workflow-review-activity-version-updated',
|
||||
namesActor: true,
|
||||
};
|
||||
case 'review.closed':
|
||||
if (!entry.data) return null;
|
||||
return {
|
||||
text: i18n.baseText(closedReasonKeys[entry.data.reason]),
|
||||
note: null,
|
||||
testId: 'workflow-review-activity-closed',
|
||||
namesActor: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const actorName = computed(() =>
|
||||
formatActorName(
|
||||
props.entry.createdBy,
|
||||
i18n.baseText('workflowReviews.detail.activity.unknownAuthor'),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="content" :class="[$style.entry, content.note && $style.boxed]">
|
||||
<N8nAvatar
|
||||
v-if="content.namesActor"
|
||||
size="xxsmall"
|
||||
:class="$style.avatar"
|
||||
:first-name="entry.createdBy?.firstName"
|
||||
:last-name="entry.createdBy?.lastName"
|
||||
/>
|
||||
<!-- Holds the avatar's column open, so an actorless sentence stays in line with its
|
||||
neighbours and the timeline above it does not end over its first word. -->
|
||||
<div v-else :class="$style.avatarSpacer" />
|
||||
<!-- Column beside the avatar, as in the comment entry, so a note lines up with the
|
||||
sentence above it instead of starting back at the avatar. -->
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.headline">
|
||||
<N8nText
|
||||
v-if="content.namesActor"
|
||||
size="medium"
|
||||
color="text-base"
|
||||
:class="$style.line"
|
||||
data-test-id="workflow-review-activity-actor"
|
||||
>
|
||||
{{ actorName }}
|
||||
<!-- Inside the name so it disappears with it, rather than leaving a dangling
|
||||
separator on the actorless entries. -->
|
||||
<span aria-hidden="true" :class="$style.separator">|</span>
|
||||
</N8nText>
|
||||
<N8nText
|
||||
size="medium"
|
||||
color="text-base"
|
||||
:class="$style.line"
|
||||
:data-test-id="content.testId"
|
||||
>
|
||||
{{ content.text }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light" :class="[$style.line, $style.timeStamp]">
|
||||
<time :datetime="entry.createdAt">
|
||||
<TimeAgo :date="entry.createdAt" />
|
||||
</time>
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nText
|
||||
v-if="content.note"
|
||||
size="medium"
|
||||
color="text-light"
|
||||
:class="[$style.body, $style.line]"
|
||||
data-test-id="workflow-review-activity-note"
|
||||
>
|
||||
{{ content.note }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowReviewActivityFallback v-else />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '../activity-card' as *;
|
||||
|
||||
.entry {
|
||||
@include activity-row;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@include activity-avatar;
|
||||
}
|
||||
|
||||
.avatarSpacer {
|
||||
flex-shrink: 0;
|
||||
width: var(--review-activity--avatar-size);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The separator carries its own wider spacing. */
|
||||
.headline {
|
||||
@include activity-headline;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin-inline: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.timeStamp {
|
||||
padding-left: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
/* A decision that carries a note sits in a card, as a comment always does. */
|
||||
.boxed {
|
||||
@include activity-card;
|
||||
}
|
||||
|
||||
/* The same line the avatar beside it is centred on, so the two cannot drift apart. */
|
||||
.line {
|
||||
line-height: var(--review-activity--line-height);
|
||||
}
|
||||
|
||||
.body {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,11 +3,19 @@ import type { Component } from 'vue';
|
|||
|
||||
import WorkflowReviewActivityComment from './activity-entries/WorkflowReviewActivityComment.vue';
|
||||
import WorkflowReviewActivityFallback from './activity-entries/WorkflowReviewActivityFallback.vue';
|
||||
import WorkflowReviewActivityEventEntry from './activity-entries/WorkflowReviewActivityEventEntry.vue';
|
||||
|
||||
// Stays partial: `workflow.published` has no writer yet, and falling back for it is the
|
||||
// designed behaviour — a total map would force a component for an entry that cannot appear.
|
||||
type ActivityEntryRegistry = Partial<Record<WorkflowReviewActivityType, Record<number, Component>>>;
|
||||
|
||||
const registry: ActivityEntryRegistry = {
|
||||
'comment.created': { 1: WorkflowReviewActivityComment },
|
||||
'review.opened': { 1: WorkflowReviewActivityEventEntry },
|
||||
'review.changes_requested': { 1: WorkflowReviewActivityEventEntry },
|
||||
'review.approved': { 1: WorkflowReviewActivityEventEntry },
|
||||
'review.version_updated': { 1: WorkflowReviewActivityEventEntry },
|
||||
'review.closed': { 1: WorkflowReviewActivityEventEntry },
|
||||
};
|
||||
|
||||
export function resolveActivityComponent(entry: WorkflowReviewActivityEntry): Component {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ describe('useReviewActivityStore', () => {
|
|||
const store = useReviewActivityStore();
|
||||
await store.fetchFeed('req-1');
|
||||
|
||||
await store.postComment('hi');
|
||||
await expect(store.postComment('hi')).resolves.toBe(true);
|
||||
|
||||
expect(store.entries.map((entry) => entry.id)).toEqual(['3', '4', '5']);
|
||||
|
||||
|
|
@ -237,6 +237,7 @@ describe('useReviewActivityStore', () => {
|
|||
|
||||
const pending = store.fetchFeed('req-1');
|
||||
store.draft = 'for req-1 only';
|
||||
store.decisionNote = 'for req-1 only';
|
||||
store.reset();
|
||||
resolveFeed(makePage(['1']));
|
||||
await pending;
|
||||
|
|
@ -245,6 +246,7 @@ describe('useReviewActivityStore', () => {
|
|||
expect(store.currentReviewId).toBeNull();
|
||||
// Leaving the view must not carry a draft into whatever review is opened next.
|
||||
expect(store.draft).toBe('');
|
||||
expect(store.decisionNote).toBe('');
|
||||
});
|
||||
|
||||
it('does not leave the next review stuck in a sending state', async () => {
|
||||
|
|
@ -361,20 +363,44 @@ describe('useReviewActivityStore', () => {
|
|||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['2']));
|
||||
await store.fetchFeed('req-2');
|
||||
resolvePost(makeEntry('9'));
|
||||
await pending;
|
||||
|
||||
// Reported to the caller too: clearing a draft or switching tabs on the back of this
|
||||
// post would hit the review the viewer is reading now.
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(store.entries.map((entry) => entry.id)).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('drops the draft when the viewer moves to another review', async () => {
|
||||
it('drops the comment and decision drafts when the viewer moves to another review', async () => {
|
||||
vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
|
||||
const store = useReviewActivityStore();
|
||||
await store.fetchFeed('req-1');
|
||||
store.draft = 'for req-1 only';
|
||||
store.decisionNote = 'for req-1 only';
|
||||
|
||||
await store.fetchFeed('req-2');
|
||||
|
||||
expect(store.draft).toBe('');
|
||||
expect(store.decisionNote).toBe('');
|
||||
|
||||
// The composer is on screen at the same time, so dropping the note must leave the
|
||||
// comment the viewer is writing alone.
|
||||
store.draft = 'half a comment';
|
||||
store.decisionNote = 'needs work';
|
||||
store.clearDecisionNote('needs work');
|
||||
expect(store.draft).toBe('half a comment');
|
||||
});
|
||||
|
||||
// Both the decision and the comment paths clear the note after awaiting a request, by
|
||||
// which time the viewer may have typed the next one.
|
||||
it('keeps a note typed while the submitted one was still in flight', () => {
|
||||
const store = useReviewActivityStore();
|
||||
store.decisionNote = 'and one more thing';
|
||||
|
||||
store.clearDecisionNote('needs work');
|
||||
expect(store.decisionNote).toBe('and one more thing');
|
||||
|
||||
store.clearDecisionNote('and one more thing');
|
||||
expect(store.decisionNote).toBe('');
|
||||
});
|
||||
|
||||
it('reports a failed comment to the composer, not as a feed error', async () => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ export const useReviewActivityStore = defineStore('workflowReviewActivity', () =
|
|||
// Held here, not in the composer: switching to the Changes tab unmounts it, and a
|
||||
// half-typed comment must survive that.
|
||||
const draft = ref('');
|
||||
// Held here, not in the popover: the popover is not keyed on the review, so a local ref
|
||||
// would carry review A's note into review B. This copy goes with the feed when the
|
||||
// selection changes.
|
||||
const decisionNote = ref('');
|
||||
|
||||
let feedRequestSeq = 0;
|
||||
let postSeq = 0;
|
||||
|
|
@ -48,6 +52,7 @@ export const useReviewActivityStore = defineStore('workflowReviewActivity', () =
|
|||
entries.value = [];
|
||||
posting.value = false;
|
||||
draft.value = '';
|
||||
decisionNote.value = '';
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -110,7 +115,11 @@ export const useReviewActivityStore = defineStore('workflowReviewActivity', () =
|
|||
}
|
||||
}
|
||||
|
||||
async function postComment(body: string) {
|
||||
/**
|
||||
* Resolves `false` when the viewer moved to another review while the post was in flight:
|
||||
* the comment was written, but nothing about it belongs on the review they are reading now.
|
||||
*/
|
||||
async function postComment(body: string): Promise<boolean> {
|
||||
const reviewId = currentReviewId.value;
|
||||
// Thrown, not swallowed: a silent return would read as success to the caller.
|
||||
if (!reviewId) throw new Error('Cannot post a comment without a selected review');
|
||||
|
|
@ -119,10 +128,11 @@ export const useReviewActivityStore = defineStore('workflowReviewActivity', () =
|
|||
posting.value = true;
|
||||
try {
|
||||
const entry = await createWorkflowReviewComment(rootStore.restApiContext, reviewId, { body });
|
||||
if (currentReviewId.value !== reviewId) return;
|
||||
if (currentReviewId.value !== reviewId) return false;
|
||||
|
||||
// A feed refetch that raced this post may already carry the comment.
|
||||
entries.value = [...entries.value.filter((existing) => existing.id !== entry.id), entry];
|
||||
return true;
|
||||
} finally {
|
||||
// Only the newest post owns the flag: after A -> B -> A a stale post finishing would
|
||||
// otherwise re-enable send while the post the user is waiting on is still in flight.
|
||||
|
|
@ -130,6 +140,14 @@ export const useReviewActivityStore = defineStore('workflowReviewActivity', () =
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the decision note, unless the viewer typed something else since the submit
|
||||
* `expected` came from — both callers await a request first.
|
||||
*/
|
||||
function clearDecisionNote(expected: string) {
|
||||
if (decisionNote.value.trim() === expected) decisionNote.value = '';
|
||||
}
|
||||
|
||||
function reset() {
|
||||
feedRequestSeq += 1;
|
||||
currentReviewId.value = null;
|
||||
|
|
@ -141,6 +159,7 @@ export const useReviewActivityStore = defineStore('workflowReviewActivity', () =
|
|||
posting.value = false;
|
||||
error.value = null;
|
||||
draft.value = '';
|
||||
decisionNote.value = '';
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -153,9 +172,11 @@ export const useReviewActivityStore = defineStore('workflowReviewActivity', () =
|
|||
posting,
|
||||
error,
|
||||
draft,
|
||||
decisionNote,
|
||||
fetchFeed,
|
||||
loadMore,
|
||||
postComment,
|
||||
clearDecisionNote,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ describe('useReviewInboxStore', () => {
|
|||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await store.decideOnReview('req-1', 'approved');
|
||||
await store.decideOnReview('req-1', { decision: 'approved' });
|
||||
|
||||
expect(store.sections.waiting.items).toEqual([]);
|
||||
expect(store.sections.authored.items).toHaveLength(1);
|
||||
|
|
@ -476,8 +476,16 @@ describe('useReviewInboxStore', () => {
|
|||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await store.decideOnReview('req-2', 'changes_requested');
|
||||
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' }),
|
||||
|
|
@ -498,7 +506,7 @@ describe('useReviewInboxStore', () => {
|
|||
autoPublish: { status: 'published' },
|
||||
});
|
||||
|
||||
const response = await store.decideOnReview('req-1', 'approved');
|
||||
const response = await store.decideOnReview('req-1', { decision: 'approved' });
|
||||
|
||||
expect(response.autoPublish).toEqual({ status: 'published' });
|
||||
});
|
||||
|
|
@ -509,7 +517,9 @@ describe('useReviewInboxStore', () => {
|
|||
new Error('forbidden'),
|
||||
);
|
||||
|
||||
await expect(store.decideOnReview('req-1', 'approved')).rejects.toThrow('forbidden');
|
||||
await expect(store.decideOnReview('req-1', { decision: 'approved' })).rejects.toThrow(
|
||||
'forbidden',
|
||||
);
|
||||
|
||||
expect(store.sections.waiting.items).toEqual([
|
||||
expect.objectContaining({ state: 'open', decision: 'pending' }),
|
||||
|
|
|
|||
|
|
@ -329,8 +329,8 @@ export const useReviewInboxStore = defineStore('workflowReviewInbox', () => {
|
|||
* waiting and authored sections. Returns the response so callers can surface
|
||||
* the auto-publish outcome.
|
||||
*/
|
||||
async function decideOnReview(id: string, decision: WorkflowReviewDecisionInput) {
|
||||
const summary = await decideWorkflowReviewRequest(rootStore.restApiContext, id, { decision });
|
||||
async function decideOnReview(id: string, input: WorkflowReviewDecisionInput) {
|
||||
const summary = await decideWorkflowReviewRequest(rootStore.restApiContext, id, input);
|
||||
|
||||
const item = findItemById(id);
|
||||
if (item) {
|
||||
|
|
|
|||
|
|
@ -77,13 +77,22 @@ const renderComponent = createComponentRenderer(WorkflowReviewRequestsView, {
|
|||
<button data-test-id="retry-waiting" @click="$emit('retry', 'waiting')" />
|
||||
</div>`,
|
||||
},
|
||||
// The real tooltip renders its content in a popper on hover, which jsdom
|
||||
// cannot exercise; expose the bindings as attributes instead.
|
||||
N8nTooltip: {
|
||||
props: ['disabled', 'content'],
|
||||
// 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-tooltip" :data-disabled="disabled" :data-content="content">
|
||||
<slot />
|
||||
<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>`,
|
||||
},
|
||||
},
|
||||
|
|
@ -308,12 +317,12 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
),
|
||||
).toBeInTheDocument();
|
||||
// The list item carries no eligibility data, so no decision actions yet
|
||||
expect(queryByTestId('workflow-review-approve-button')).not.toBeInTheDocument();
|
||||
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-approve-button')).toBeInTheDocument();
|
||||
expect(getByTestId('workflow-review-decision-popover')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates the tab from the query before probing', async () => {
|
||||
|
|
@ -478,32 +487,23 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
store.decideOnReview.mockResolvedValue(decisionResponse());
|
||||
});
|
||||
|
||||
it('renders both action buttons for an open review', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(getByTestId('workflow-review-approve-button')).toBeInTheDocument();
|
||||
expect(getByTestId('workflow-review-request-changes-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the action buttons for a closed review', async () => {
|
||||
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-approve-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-request-changes-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits an approval for the selected review', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(store.decideOnReview).toHaveBeenCalledWith('req-1', 'approved');
|
||||
expect(store.decideOnReview).toHaveBeenCalledWith('req-1', { decision: 'approved' });
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -511,10 +511,13 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-request-changes-button').click();
|
||||
getByTestId('request-changes').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(store.decideOnReview).toHaveBeenCalledWith('req-1', 'changes_requested');
|
||||
expect(store.decideOnReview).toHaveBeenCalledWith('req-1', {
|
||||
decision: 'changes_requested',
|
||||
note: 'needs work',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a success toast when the approval published the workflow', async () => {
|
||||
|
|
@ -525,7 +528,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(showMessage).toHaveBeenCalledWith({
|
||||
|
|
@ -544,7 +547,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(showMessage).toHaveBeenCalledWith({
|
||||
|
|
@ -566,7 +569,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
|
|
@ -584,7 +587,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-request-changes-button').click();
|
||||
getByTestId('request-changes').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
|
|
@ -598,7 +601,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
|
|
@ -617,7 +620,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
|
|
@ -633,7 +636,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-request-changes-button').click();
|
||||
getByTestId('request-changes').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/workflow-review-requests/req-1');
|
||||
|
|
@ -647,7 +650,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
await waitAllPromises();
|
||||
const replaceSpy = vi.spyOn(router, 'replace');
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
|
|
@ -662,7 +665,7 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
|
||||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision');
|
||||
|
|
@ -670,83 +673,151 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
|
||||
// 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 as well as both sections when the decision fails', async () => {
|
||||
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('workflow-review-approve-button').click();
|
||||
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();
|
||||
});
|
||||
|
||||
// Each button carries its own tooltip, so both must agree.
|
||||
it('keeps the buttons enabled and the tooltips off when the viewer can decide', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
for (const button of decisionButtons(getByTestId)) {
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(decisionTooltip(button)).toHaveAttribute('data-disabled', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
it('disables the buttons and says why when the viewer contributed a version', async () => {
|
||||
store.detail = createDetail({
|
||||
viewerCanDecide: false,
|
||||
viewerDecisionIneligibilityReason: 'author',
|
||||
});
|
||||
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();
|
||||
|
||||
for (const button of decisionButtons(getByTestId)) {
|
||||
expect(button).toBeDisabled();
|
||||
expect(decisionTooltip(button)).toHaveAttribute('data-disabled', 'false');
|
||||
expect(decisionTooltip(button)).toHaveAttribute(
|
||||
'data-content',
|
||||
'You contributed a version to this review.',
|
||||
);
|
||||
}
|
||||
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('falls back to the generic permission hint for any other reason', async () => {
|
||||
store.detail = createDetail({
|
||||
viewerCanDecide: false,
|
||||
viewerDecisionIneligibilityReason: 'missing_reviewer_permission',
|
||||
});
|
||||
// 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('/workflow-review-requests/req-2');
|
||||
await waitAllPromises();
|
||||
activityStore.fetchFeed.mockClear();
|
||||
|
||||
for (const button of decisionButtons(getByTestId)) {
|
||||
expect(button).toBeDisabled();
|
||||
expect(decisionTooltip(button)).toHaveAttribute(
|
||||
'data-content',
|
||||
'Missing permissions to perform this action',
|
||||
);
|
||||
}
|
||||
resolveDecision();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(activityStore.fetchFeed).not.toHaveBeenCalled();
|
||||
expect(activityStore.clearDecisionNote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit a decision for an ineligible viewer', async () => {
|
||||
store.detail = createDetail({
|
||||
viewerCanDecide: false,
|
||||
viewerDecisionIneligibilityReason: 'author',
|
||||
});
|
||||
// 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('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await router.replace('/workflow-review-requests/req-2');
|
||||
await waitAllPromises();
|
||||
|
||||
expect(store.decideOnReview).not.toHaveBeenCalled();
|
||||
resolveDecision();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/workflow-review-requests/req-2');
|
||||
});
|
||||
|
||||
it('disables both buttons while a decision is in flight', async () => {
|
||||
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();
|
||||
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 () =>
|
||||
|
|
@ -758,17 +829,21 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
getByTestId('workflow-review-approve-button').click();
|
||||
getByTestId('approve-review').click();
|
||||
await vi.waitFor(() => {
|
||||
expect(getByTestId('workflow-review-approve-button')).toBeDisabled();
|
||||
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
|
||||
'data-deciding',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
expect(getByTestId('workflow-review-request-changes-button')).toBeDisabled();
|
||||
|
||||
resolveDecision();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(getByTestId('workflow-review-approve-button')).not.toBeDisabled();
|
||||
expect(getByTestId('workflow-review-request-changes-button')).not.toBeDisabled();
|
||||
expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute(
|
||||
'data-deciding',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -808,23 +883,6 @@ describe('WorkflowReviewRequestsView', () => {
|
|||
});
|
||||
});
|
||||
|
||||
function decisionButtons(getByTestId: (id: string) => HTMLElement) {
|
||||
return [
|
||||
getByTestId('workflow-review-approve-button'),
|
||||
getByTestId('workflow-review-request-changes-button'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab bar renders a tooltip per tab, which the N8nTooltip stub matches too,
|
||||
* so walk up from the button rather than querying the test id globally.
|
||||
*/
|
||||
function decisionTooltip(button: HTMLElement) {
|
||||
const tooltip = button.closest('[data-test-id="workflow-review-decision-tooltip"]');
|
||||
if (!tooltip) throw new Error('decision button is not wrapped in a tooltip');
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function createInboxItem(): WorkflowReviewInboxItem {
|
||||
return {
|
||||
id: 'req-1',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ function firstParam(value: string | string[] | undefined): string | 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';
|
||||
}
|
||||
|
|
@ -93,7 +101,7 @@ function handleListError(error: unknown) {
|
|||
watch(
|
||||
selectedReviewId,
|
||||
(id) => {
|
||||
if (route.name !== WORKFLOW_REVIEW_REQUESTS_VIEW) return;
|
||||
if (!isOnInbox()) return;
|
||||
if (id) {
|
||||
void store.fetchDetail(id).catch(handleListError);
|
||||
// Failures surface in the feed's own error row, never as a second toast.
|
||||
|
|
@ -109,7 +117,7 @@ watch(
|
|||
watch(
|
||||
() => route.query[REVIEW_INBOX_QUERY_PARAM.state],
|
||||
(next) => {
|
||||
if (route.name !== WORKFLOW_REVIEW_REQUESTS_VIEW) return;
|
||||
if (!isOnInbox()) return;
|
||||
void store.setActiveTab(stateFromQuery(next)).catch(handleListError);
|
||||
},
|
||||
);
|
||||
|
|
@ -137,6 +145,7 @@ const detailTab = computed<WorkflowReviewDetailTab>(() =>
|
|||
);
|
||||
|
||||
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];
|
||||
|
|
@ -174,6 +183,7 @@ function asSentence(message: string) {
|
|||
* refetches the list from here.
|
||||
*/
|
||||
function followClosedReview(id: string) {
|
||||
if (!isOnInbox()) return;
|
||||
if (activeTab.value === 'closed') return;
|
||||
void router.replace({
|
||||
params: { reviewRequestId: id },
|
||||
|
|
@ -181,14 +191,27 @@ function followClosedReview(id: string) {
|
|||
});
|
||||
}
|
||||
|
||||
async function onDecide(id: string, decision: WorkflowReviewDecisionInput) {
|
||||
async function onDecide(id: string, input: WorkflowReviewDecisionInput) {
|
||||
deciding.value = true;
|
||||
try {
|
||||
const { autoPublish, state } = await store.decideOnReview(id, decision);
|
||||
if (state === 'closed') {
|
||||
followClosedReview(id);
|
||||
const { autoPublish, state } = await store.decideOnReview(id, input);
|
||||
// The selection does not change, so the `selectedReviewId` watcher never refires and the
|
||||
// entry this decision just wrote needs an explicit refetch. 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') {
|
||||
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',
|
||||
|
|
@ -208,6 +231,9 @@ async function onDecide(id: string, decision: WorkflowReviewDecisionInput) {
|
|||
});
|
||||
}
|
||||
} 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
|
||||
|
|
@ -220,6 +246,9 @@ async function onDecide(id: string, decision: WorkflowReviewDecisionInput) {
|
|||
} catch (refetchError) {
|
||||
handleListError(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ export type FetchWorkflowReviewInboxParams = {
|
|||
cursor?: string;
|
||||
};
|
||||
|
||||
/** A decision a reviewer can submit; `pending` is the initial state, never an input. */
|
||||
export type WorkflowReviewDecisionInput = DecideWorkflowReviewRequestDto['decision'];
|
||||
/** 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(
|
||||
|
|
|
|||
|
|
@ -11,3 +11,8 @@ type UserNameParts = {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user