From 8bee1eeb20641ad2803e8893aa66575e53207d62 Mon Sep 17 00:00:00 2001 From: Predrag Ristic Date: Fri, 16 Oct 2026 09:37:52 +0000 Subject: [PATCH] refactor(core): Prepare the shared Inbox (#40739) Co-authored-by: kgrhartlage --- OWNERS | 1 + .../inbox/__tests__/list-inbox.dto.test.ts | 33 + .../api-types/src/dto/inbox/list-inbox.dto.ts | 12 + packages/@n8n/api-types/src/dto/index.ts | 3 +- ...st-workflow-review-inbox-query.dto.test.ts | 45 - .../list-workflow-review-inbox.dto.ts | 56 +- .../@n8n/api-types/src/frontend-settings.ts | 2 + packages/@n8n/api-types/src/inbox.ts | 45 + packages/@n8n/api-types/src/index.ts | 1 + .../modules/__tests__/module-registry.test.ts | 6 +- .../src/modules/module-registry.ts | 1 + .../src/modules/modules.config.ts | 1 + .../workflow-review.repositories.test.ts | 15 +- packages/@n8n/db/src/repositories/index.ts | 2 +- .../workflow-review-inbox.repository.ts | 23 +- .../__tests__/inbox-source.registry.test.ts | 17 + .../inbox/__tests__/inbox.controller.test.ts | 37 + .../inbox/__tests__/inbox.service.test.ts | 354 +++++++ .../modules/inbox/inbox-source.registry.ts | 56 + .../cli/src/modules/inbox/inbox.controller.ts | 22 + .../cli/src/modules/inbox/inbox.module.ts | 8 + .../cli/src/modules/inbox/inbox.service.ts | 241 +++++ ...view-detail.controller.integration.test.ts | 6 +- ...eview-inbox.controller.integration.test.ts | 144 ++- ...workflow-review-inbox.list.service.test.ts | 119 +-- ...orkflow-review-requests.controller.test.ts | 2 - ...sion-update.controller.integration.test.ts | 4 +- .../workflow-review-inbox.service.ts | 104 +- .../workflow-review-requests.controller.ts | 23 - .../workflow-reviews.module.ts | 9 + .../__tests__/frontend.service.test.ts | 42 + packages/cli/src/services/frontend.service.ts | 5 + packages/cli/test/integration/shared/types.ts | 1 + .../src}/useIntersectionObserver.test.ts | 24 +- .../src/useIntersectionObserver.ts | 92 ++ .../src/useWorkflowVersionLabel.ts | 23 + .../src/types/component.ts | 16 +- .../@n8n/frontend-vite-config/index.ts | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 30 +- .../@n8n/stores/src/settings.store.test.ts | 50 + .../@n8n/stores/src/settings.store.ts | 15 + packages/frontend/editor-ui/package.json | 1 + .../src/app/componentSlots.manifest.ts | 7 + .../WorkflowHeaderDraftPublishActions.test.ts | 14 +- .../WorkflowHeaderDraftPublishActions.vue | 4 +- .../composables/useIntersectionObserver.ts | 93 +- .../src/app/constants/localStorage.ts | 3 - .../editor-ui/src/app/modules.manifest.ts | 4 +- .../projects/components/ProjectNavigation.vue | 21 +- .../projects/composables/useProjectPages.ts | 4 +- .../projects/projects.store.test.ts | 14 +- .../collaboration/projects/projects.store.ts | 4 +- .../security/SecuritySettings.test.ts | 19 + .../WorkflowReviewRequestsSidebar.test.ts | 386 ------- .../WorkflowReviewRequestsSidebar.vue | 527 ---------- .../WorkflowReviewSubmittedDialog.test.ts | 4 +- .../WorkflowReviewSubmittedDialog.vue | 2 +- .../WorkflowUpdateReviewDialog.test.ts | 7 +- .../components/WorkflowUpdateReviewDialog.vue | 2 +- .../features/workflow-reviews/constants.ts | 46 +- .../workflow-reviews/module.descriptor.ts | 34 - .../reviewInbox.store.test.ts | 800 --------------- .../workflow-reviews/reviewInbox.store.ts | 388 ------- .../views/WorkflowReviewRequestsView.test.ts | 969 ------------------ .../views/WorkflowReviewRequestsView.vue | 515 ---------- .../workflow-reviews/workflowReviews.api.ts | 83 -- .../workflow-reviews/workflowReviews.utils.ts | 19 +- .../workflowDiff/WorkflowDiffSlot.test.ts | 108 ++ .../workflowDiff/WorkflowDiffSlot.vue | 50 + .../workflows/workflowHistory/utils.ts | 26 +- packages/frontend/editor-ui/tsconfig.json | 2 + packages/modules/inbox/frontend/README.md | 60 ++ packages/modules/inbox/frontend/biome.jsonc | 4 + .../modules/inbox/frontend/eslint.config.mjs | 4 + packages/modules/inbox/frontend/package.json | 65 ++ .../src/__tests__/design-system-icons.test.ts | 24 + .../inbox/frontend/src/__tests__/setup.ts | 4 + .../src/components/InboxEmptyState.vue | 86 ++ .../frontend/src/components/InboxList.test.ts | 229 +++++ .../frontend/src/components/InboxList.vue | 195 ++++ .../frontend/src/components/InboxListItem.vue | 134 +++ .../src/components/InboxListSection.vue | 168 +++ .../useReviewInboxSectionCollapse.test.ts | 26 +- .../useReviewInboxSectionCollapse.ts | 3 +- .../modules/inbox/frontend/src/inbox.api.ts | 20 + .../inbox/frontend/src/inbox.constants.ts | 16 + .../inbox/frontend/src/inbox.module.ts | 50 + .../inbox/frontend/src/inbox.routes.test.ts | 111 ++ .../inbox/frontend/src/inbox.routes.ts | 30 + .../inbox/frontend/src/inbox.store.test.ts | 349 +++++++ .../modules/inbox/frontend/src/inbox.store.ts | 305 ++++++ packages/modules/inbox/frontend/src/index.ts | 5 + .../src/reviews/WorkflowReviewDetail.test.ts | 647 ++++++++++++ .../src/reviews/WorkflowReviewDetail.vue | 257 +++++ .../WorkflowReviewActivityFeed.test.ts | 9 +- .../components/WorkflowReviewActivityFeed.vue | 2 +- .../WorkflowReviewChangesSection.test.ts | 87 +- .../WorkflowReviewChangesSection.vue | 66 +- .../WorkflowReviewCommentComposer.test.ts | 30 +- .../WorkflowReviewCommentComposer.vue | 0 .../WorkflowReviewDecisionPopover.test.ts | 20 +- .../WorkflowReviewDecisionPopover.vue | 2 +- .../WorkflowReviewDetailMetadata.test.ts | 4 +- .../WorkflowReviewDetailMetadata.vue | 2 +- ...kflowReviewDetailTabs.decisionNote.test.ts | 2 +- .../WorkflowReviewDetailTabs.test.ts | 2 +- .../components/WorkflowReviewDetailTabs.vue | 2 +- .../components/WorkflowReviewStatusDot.vue | 0 .../reviews}/components/_activity-card.scss | 0 .../WorkflowReviewActivityActorAvatar.vue | 0 .../WorkflowReviewActivityComment.test.ts | 2 +- .../WorkflowReviewActivityComment.vue | 7 +- .../WorkflowReviewActivityEventEntry.vue | 12 +- .../WorkflowReviewActivityFallback.vue | 0 .../WorkflowReviewActivityWorkflowLink.vue | 2 +- .../components/activityEntryRegistry.ts | 0 .../inbox/frontend/src/reviews/constants.ts | 41 + .../src/reviews}/reviewActivity.store.test.ts | 20 + .../src/reviews}/reviewActivity.store.ts | 14 +- .../src/reviews/reviewDetail.store.test.ts | 135 +++ .../src/reviews/reviewDetail.store.ts | 74 ++ .../reviews}/workflowReviewStatus.utils.ts | 0 .../src/reviews/workflowReviews.api.ts | 61 ++ .../src/reviews/workflowReviews.utils.ts | 18 + .../frontend/src/views/InboxView.test.ts | 525 ++++++++++ .../inbox/frontend/src/views/InboxView.vue | 251 +++++ .../inbox/frontend/stylelint.config.mjs | 9 + packages/modules/inbox/frontend/tsconfig.json | 21 + .../modules/inbox/frontend/vite.config.ts | 52 + .../playwright/pages/WorkflowReviewsPage.ts | 8 +- pnpm-lock.yaml | 121 +++ 131 files changed, 5804 insertions(+), 4436 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/inbox/__tests__/list-inbox.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/inbox/list-inbox.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/workflow-reviews/__tests__/list-workflow-review-inbox-query.dto.test.ts create mode 100644 packages/@n8n/api-types/src/inbox.ts create mode 100644 packages/cli/src/modules/inbox/__tests__/inbox-source.registry.test.ts create mode 100644 packages/cli/src/modules/inbox/__tests__/inbox.controller.test.ts create mode 100644 packages/cli/src/modules/inbox/__tests__/inbox.service.test.ts create mode 100644 packages/cli/src/modules/inbox/inbox-source.registry.ts create mode 100644 packages/cli/src/modules/inbox/inbox.controller.ts create mode 100644 packages/cli/src/modules/inbox/inbox.module.ts create mode 100644 packages/cli/src/modules/inbox/inbox.service.ts rename packages/frontend/{editor-ui/src/app/composables => @n8n/composables/src}/useIntersectionObserver.test.ts (89%) create mode 100644 packages/frontend/@n8n/composables/src/useIntersectionObserver.ts create mode 100644 packages/frontend/@n8n/composables/src/useWorkflowVersionLabel.ts delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.test.ts delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.vue delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/module.descriptor.ts delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.test.ts delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.ts delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.test.ts delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.vue create mode 100644 packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.test.ts create mode 100644 packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.vue create mode 100644 packages/modules/inbox/frontend/README.md create mode 100644 packages/modules/inbox/frontend/biome.jsonc create mode 100644 packages/modules/inbox/frontend/eslint.config.mjs create mode 100644 packages/modules/inbox/frontend/package.json create mode 100644 packages/modules/inbox/frontend/src/__tests__/design-system-icons.test.ts create mode 100644 packages/modules/inbox/frontend/src/__tests__/setup.ts create mode 100644 packages/modules/inbox/frontend/src/components/InboxEmptyState.vue create mode 100644 packages/modules/inbox/frontend/src/components/InboxList.test.ts create mode 100644 packages/modules/inbox/frontend/src/components/InboxList.vue create mode 100644 packages/modules/inbox/frontend/src/components/InboxListItem.vue create mode 100644 packages/modules/inbox/frontend/src/components/InboxListSection.vue rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src}/composables/useReviewInboxSectionCollapse.test.ts (67%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src}/composables/useReviewInboxSectionCollapse.ts (88%) create mode 100644 packages/modules/inbox/frontend/src/inbox.api.ts create mode 100644 packages/modules/inbox/frontend/src/inbox.constants.ts create mode 100644 packages/modules/inbox/frontend/src/inbox.module.ts create mode 100644 packages/modules/inbox/frontend/src/inbox.routes.test.ts create mode 100644 packages/modules/inbox/frontend/src/inbox.routes.ts create mode 100644 packages/modules/inbox/frontend/src/inbox.store.test.ts create mode 100644 packages/modules/inbox/frontend/src/inbox.store.ts create mode 100644 packages/modules/inbox/frontend/src/index.ts create mode 100644 packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.test.ts create mode 100644 packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.vue rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewActivityFeed.test.ts (99%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewActivityFeed.vue (99%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewChangesSection.test.ts (80%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewChangesSection.vue (81%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewCommentComposer.test.ts (80%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewCommentComposer.vue (100%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewDecisionPopover.test.ts (92%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewDecisionPopover.vue (100%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewDetailMetadata.test.ts (97%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewDetailMetadata.vue (99%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewDetailTabs.decisionNote.test.ts (97%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewDetailTabs.test.ts (99%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewDetailTabs.vue (99%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/WorkflowReviewStatusDot.vue (100%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/_activity-card.scss (100%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/activity-entries/WorkflowReviewActivityActorAvatar.vue (100%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/activity-entries/WorkflowReviewActivityComment.test.ts (97%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/activity-entries/WorkflowReviewActivityComment.vue (91%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/activity-entries/WorkflowReviewActivityEventEntry.vue (96%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/activity-entries/WorkflowReviewActivityFallback.vue (100%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue (93%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/components/activityEntryRegistry.ts (100%) create mode 100644 packages/modules/inbox/frontend/src/reviews/constants.ts rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/reviewActivity.store.test.ts (94%) rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/reviewActivity.store.ts (93%) create mode 100644 packages/modules/inbox/frontend/src/reviews/reviewDetail.store.test.ts create mode 100644 packages/modules/inbox/frontend/src/reviews/reviewDetail.store.ts rename packages/{frontend/editor-ui/src/features/workflow-reviews => modules/inbox/frontend/src/reviews}/workflowReviewStatus.utils.ts (100%) create mode 100644 packages/modules/inbox/frontend/src/reviews/workflowReviews.api.ts create mode 100644 packages/modules/inbox/frontend/src/reviews/workflowReviews.utils.ts create mode 100644 packages/modules/inbox/frontend/src/views/InboxView.test.ts create mode 100644 packages/modules/inbox/frontend/src/views/InboxView.vue create mode 100644 packages/modules/inbox/frontend/stylelint.config.mjs create mode 100644 packages/modules/inbox/frontend/tsconfig.json create mode 100644 packages/modules/inbox/frontend/vite.config.ts diff --git a/OWNERS b/OWNERS index 36a909af3c7..0070fb21fcb 100644 --- a/OWNERS +++ b/OWNERS @@ -123,6 +123,7 @@ packages/cli/src/modules/mcp/ @n8n-io/context packages/modules/quick-connect/ @n8n-io/ai packages/cli/src/modules/chat-hub/ @n8n-io/ai-assistant packages/cli/src/modules/instance-ai/ @n8n-io/ai-assistant +packages/modules/inbox/ @n8n-io/ligo packages/cli/src/modules/community-packages/ @n8n-io/nodes diff --git a/packages/@n8n/api-types/src/dto/inbox/__tests__/list-inbox.dto.test.ts b/packages/@n8n/api-types/src/dto/inbox/__tests__/list-inbox.dto.test.ts new file mode 100644 index 00000000000..a5597d2914c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/inbox/__tests__/list-inbox.dto.test.ts @@ -0,0 +1,33 @@ +import { ListInboxQueryDto } from '../list-inbox.dto'; + +describe('ListInboxQueryDto', () => { + it('defaults to the first Open page', () => { + expect(ListInboxQueryDto.parse({})).toEqual({ state: 'open', limit: 15 }); + }); + + it('accepts query parameters for a Closed continuation page', () => { + expect(ListInboxQueryDto.parse({ state: 'closed', limit: '20', cursor: 'cursor' })).toEqual({ + state: 'closed', + limit: 20, + cursor: 'cursor', + }); + }); + + it.each([ + { category: 'other' }, + { category: ['waiting', 'authored'] }, + { state: 'pending' }, + { state: ['open', 'closed'] }, + { limit: 0 }, + { limit: 101 }, + { limit: 1.5 }, + { cursor: '' }, + { cursor: 'a'.repeat(2049) }, + ])('rejects invalid page input %j', (query) => { + expect(ListInboxQueryDto.safeParse(query).success).toBe(false); + }); + + it.each(['waiting', 'authored'])('accepts the %s category', (category) => { + expect(ListInboxQueryDto.parse({ category })).toEqual({ state: 'open', limit: 15, category }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/inbox/list-inbox.dto.ts b/packages/@n8n/api-types/src/dto/inbox/list-inbox.dto.ts new file mode 100644 index 00000000000..5077ec24edf --- /dev/null +++ b/packages/@n8n/api-types/src/dto/inbox/list-inbox.dto.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { inboxCategorySchema, inboxStateSchema } from '../../inbox'; +import { Z } from '../../zod-class'; + +export class ListInboxQueryDto extends Z.class({ + state: inboxStateSchema.default('open'), + /** Filters the Open list. Omit for the combined list and the Closed list. */ + category: inboxCategorySchema.optional(), + limit: z.coerce.number().int().min(1).max(100).default(15), + cursor: z.string().min(1).max(2048).optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index ab15b76eedb..a6fdffed2f2 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -651,7 +651,8 @@ export { type WorkflowReviewRequestList, } from './workflow-reviews/list-workflow-review-requests-query.dto'; export { UpdateWorkflowReviewRequestVersionDto } from './workflow-reviews/update-workflow-review-request-version.dto'; -export * from './workflow-reviews/list-workflow-review-inbox.dto'; +export type { WorkflowReviewInboxItem } from './workflow-reviews/list-workflow-review-inbox.dto'; +export { ListInboxQueryDto } from './inbox/list-inbox.dto'; export type * from './workflow-reviews/get-workflow-review-request-detail.dto'; export { ListWorkflowReviewActivityQueryDto, diff --git a/packages/@n8n/api-types/src/dto/workflow-reviews/__tests__/list-workflow-review-inbox-query.dto.test.ts b/packages/@n8n/api-types/src/dto/workflow-reviews/__tests__/list-workflow-review-inbox-query.dto.test.ts deleted file mode 100644 index 8799eeca5c9..00000000000 --- a/packages/@n8n/api-types/src/dto/workflow-reviews/__tests__/list-workflow-review-inbox-query.dto.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ListWorkflowReviewInboxQueryDto } from '../list-workflow-review-inbox.dto'; - -describe('ListWorkflowReviewInboxQueryDto', () => { - describe('accepted', () => { - test.each([ - { - name: 'no filters at all', - request: {}, - parsedResult: { limit: 15 }, - }, - { - name: 'category waiting', - request: { category: 'waiting' }, - parsedResult: { limit: 15, category: 'waiting' }, - }, - { - name: 'category authored', - request: { category: 'authored' }, - parsedResult: { limit: 15, category: 'authored' }, - }, - { - name: 'category alongside state, limit and cursor', - request: { category: 'authored', state: 'closed', limit: '30', cursor: 'abc' }, - parsedResult: { category: 'authored', state: 'closed', limit: 30, cursor: 'abc' }, - }, - ])('accepts $name', ({ request, parsedResult }) => { - const result = ListWorkflowReviewInboxQueryDto.safeParse(request); - expect(result.success).toBe(true); - expect(result.data).toMatchObject(parsedResult); - }); - }); - - describe('rejected', () => { - test.each([ - { name: 'unknown category', request: { category: 'mine' } }, - { name: 'empty category', request: { category: '' } }, - { name: 'boolean-ish category', request: { category: 'true' } }, - { name: 'category as an array', request: { category: ['waiting'] } }, - ])('rejects $name', ({ request }) => { - const result = ListWorkflowReviewInboxQueryDto.safeParse(request); - expect(result.success).toBe(false); - expect(result.error?.issues[0].path).toEqual(['category']); - }); - }); -}); diff --git a/packages/@n8n/api-types/src/dto/workflow-reviews/list-workflow-review-inbox.dto.ts b/packages/@n8n/api-types/src/dto/workflow-reviews/list-workflow-review-inbox.dto.ts index f7e57c764e4..963f2ab527b 100644 --- a/packages/@n8n/api-types/src/dto/workflow-reviews/list-workflow-review-inbox.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflow-reviews/list-workflow-review-inbox.dto.ts @@ -1,16 +1,7 @@ -import { z } from 'zod'; - import type { WorkflowReviewEligibleReviewer } from '../../workflow-review-eligible-reviewer'; -import { - type WorkflowReviewRequestSummary, - workflowReviewRequestStateSchema, -} from '../../workflow-review-request-summary'; -import { Z } from '../../zod-class'; +import type { WorkflowReviewRequestSummary } from '../../workflow-review-request-summary'; -/** - * Inbox list item — extends {@link WorkflowReviewRequestSummary} with the - * cross-project fields the inbox needs. - */ +/** Review fields used by the Inbox row and detail response. */ export interface WorkflowReviewInboxItem extends WorkflowReviewRequestSummary { projectId: string; title: string; @@ -19,46 +10,3 @@ export interface WorkflowReviewInboxItem extends WorkflowReviewRequestSummary { authors: WorkflowReviewEligibleReviewer[]; reviewers: WorkflowReviewEligibleReviewer[]; } - -export const workflowReviewInboxCategorySchema = z.enum(['waiting', 'authored']); - -/** - * Which group of reviews to return: `waiting` = the caller is an assigned - * reviewer, or is not an author; `authored` = the caller authored it and is not - * assigned to review it. Being a reviewer wins, so a review sits where the - * caller's pending action is. - */ -export type WorkflowReviewInboxCategory = z.infer; - -/** - * Inbox query. Defaults to open requests when `state` is omitted. - */ -export class ListWorkflowReviewInboxQueryDto extends Z.class({ - limit: z.coerce.number().int().min(1).max(100).default(15), - // Opaque base64url keyset cursor (ISO createdAt + id), not a bare UUID. - cursor: z.string().min(1).max(256).optional(), - state: workflowReviewRequestStateSchema.optional(), - /** - * Which group of reviews to return — see {@link WorkflowReviewInboxCategory}. - * Requesting a review and contributing a version both count as authoring it. - * Every visible review falls in exactly one group, so the two add up to the - * unfiltered list. - * - * Independent of `state`: it filters closed reviews the same way. The editor - * sends it only for the open tab, which is why the closed list stays flat. - * - * Omitted means no filter. Narrows visibility, never widens it. - */ - category: workflowReviewInboxCategorySchema.optional(), -}) {} - -export interface ListWorkflowReviewInboxResponse { - data: WorkflowReviewInboxItem[]; - nextCursor: string | null; - hasMore: boolean; -} - -export interface GetWorkflowReviewInboxSummaryResponse { - open: number; - closed: number; -} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 1585fd393d5..55a839a8b38 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -6,6 +6,7 @@ import type { ChatProviderSettingsDto, } from './chat-hub'; import type { QuickConnectOption } from './quick-connect'; +import type { InboxSettings } from './inbox'; import type { InsightsDateRange } from './schemas/insights.schema'; import type { WorkflowReviewsPolicy } from './workflow-reviews-policy'; @@ -239,6 +240,7 @@ export interface FrontendSettings { enforced: boolean; }; workflowReviews?: WorkflowReviewsPolicy; + inbox?: InboxSettings; folders: { enabled: boolean; }; diff --git a/packages/@n8n/api-types/src/inbox.ts b/packages/@n8n/api-types/src/inbox.ts new file mode 100644 index 00000000000..8e9c695a7a9 --- /dev/null +++ b/packages/@n8n/api-types/src/inbox.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import type { WorkflowReviewInboxItem } from './dto/workflow-reviews/list-workflow-review-inbox.dto'; + +export const inboxSourceTypeSchema = z.enum(['workflow_review']); +export type InboxSourceType = z.infer; + +export const inboxStateSchema = z.enum(['open', 'closed']); +export type InboxState = z.infer; + +export const inboxCategorySchema = z.enum(['waiting', 'authored']); +export type InboxCategory = z.infer; + +export type InboxWorkflowReviewItem = WorkflowReviewInboxItem & { + type: 'workflow_review'; +}; + +export type InboxItem = InboxWorkflowReviewItem; + +export type InboxCounts = { open: number; closed: number }; + +export type InboxSourceStatus = { + /** True when at least one source failed. Disabled sources do not make the response partial. */ + partial: boolean; + /** Sources that failed to load. A retry can recover them. */ + failedSources: InboxSourceType[]; + /** Sources that are unavailable because their feature is disabled or unlicensed. */ + disabledSources: InboxSourceType[]; +}; + +export type ListInboxResponse = InboxSourceStatus & { + data: InboxItem[]; + hasMore: boolean; + nextCursor: string | null; +}; + +export type GetInboxSummaryResponse = InboxSourceStatus & { + counts: InboxCounts | null; +}; + +export type InboxSettings = { + enabled: boolean; + availableTypes: InboxSourceType[]; + failedTypes: InboxSourceType[]; +}; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index a3732e0e057..ab15153af30 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -980,6 +980,7 @@ export { compareExecutionListItems } from './dto/executions/compare-execution-li export type * from './workflow-suggestions'; export * from './self-healing-results'; +export * from './inbox'; export * from './model-priority'; export { diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 514c2a325e1..20f4e6fa4df 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -57,14 +57,14 @@ describe('eligibleModules', () => { expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('policy-infrastructure'); }); - it.each(['instance-ai', 'agents', 'agent-evals'])( + it.each(['instance-ai', 'agents', 'agent-evals', 'inbox'])( 'should include %s by default', (moduleName) => { expect(Container.get(ModuleRegistry).eligibleModules).toContain(moduleName); }, ); - it.each(['instance-ai', 'agents', 'agent-evals'])( + it.each(['instance-ai', 'agents', 'agent-evals', 'inbox'])( 'should allow opting out of the default %s module via env var', (moduleName) => { process.env.N8N_DISABLED_MODULES = moduleName; @@ -105,6 +105,7 @@ describe('eligibleModules', () => { 'runtime-credentials', 'mcp-registry', 'workflow-reviews', + 'inbox', 'instance-ai', 'agents', 'agent-evals', @@ -146,6 +147,7 @@ describe('eligibleModules', () => { 'runtime-credentials', 'mcp-registry', 'workflow-reviews', + 'inbox', 'instance-ai', 'agents', 'agent-evals', diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 5324c11e703..1f4cc011b7c 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -81,6 +81,7 @@ export class ModuleRegistry { 'runtime-credentials', 'mcp-registry', 'workflow-reviews', + 'inbox', 'instance-ai', 'agents', 'agent-evals', diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index d072fb37cb1..2a849928b2d 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -3,6 +3,7 @@ import { CommaSeparatedStringArray, Config, Env } from '@n8n/config'; import { UnknownModuleError } from './errors/unknown-module.error'; export const MODULE_NAMES = [ + 'inbox', 'agents', 'agent-evals', 'insights', diff --git a/packages/@n8n/db/src/repositories/__tests__/workflow-review.repositories.test.ts b/packages/@n8n/db/src/repositories/__tests__/workflow-review.repositories.test.ts index 2ecbf5c1511..d952448fe01 100644 --- a/packages/@n8n/db/src/repositories/__tests__/workflow-review.repositories.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/workflow-review.repositories.test.ts @@ -614,7 +614,7 @@ describe('workflow review repositories', () => { await inboxRepository.findRequests({ visibility: involvedVisibility(), limit: 10, - cursor: { createdAt, id: 'req-cursor' }, + boundary: { mode: 'afterItem', createdAt, id: 'req-cursor' }, }); expect(findOneSpy).not.toHaveBeenCalled(); @@ -624,6 +624,17 @@ describe('workflow review repositories', () => { ); }); + it('uses older timestamps for a boundary from another source', async () => { + const createdAt = new Date('2026-10-07T00:00:00.000Z'); + await inboxRepository.findRequests({ + visibility: allVisibility, + limit: 10, + boundary: { mode: 'beforeTime', createdAt }, + }); + expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.createdAt < :createdAt', { + createdAt, + }); + }); describe('category filter', () => { it('leaves the query untouched when no category is requested', async () => { await inboxRepository.findRequests({ @@ -704,7 +715,7 @@ describe('workflow review repositories', () => { category: { userId: 'user-1', category: 'authored' }, state: 'open', limit: 15, - cursor: { createdAt, id: 'req-cursor' }, + boundary: { mode: 'afterItem', createdAt, id: 'req-cursor' }, }); const categoryCall = queryBuilder.andWhere.mock.invocationCallOrder[0]; diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index 9027d24654e..b06d1de1fb5 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -127,7 +127,7 @@ export { export { WorkflowReviewLifecycleRepository } from './workflow-review-lifecycle.repository'; export { WorkflowReviewInboxRepository, - type InboxCursor, + type WorkflowReviewInboxBoundary, type InboxVisibility, } from './workflow-review-inbox.repository'; export { diff --git a/packages/@n8n/db/src/repositories/workflow-review-inbox.repository.ts b/packages/@n8n/db/src/repositories/workflow-review-inbox.repository.ts index 82f9fbf5de8..5ef4c933152 100644 --- a/packages/@n8n/db/src/repositories/workflow-review-inbox.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-review-inbox.repository.ts @@ -12,18 +12,13 @@ import { } from '../entities/workflow-review-request.ee'; import { TransactionRunner } from '../services/transaction'; -/** The cursor carries its boundary values so deleting the previous page's last row is safe. */ -export type InboxCursor = { - createdAt: Date; - id: string; -}; +export type WorkflowReviewInboxBoundary = + | { mode: 'beforeTime'; createdAt: Date } + | { mode: 'afterItem'; createdAt: Date; id: string }; -/** - * Reviewers belong in `waiting`, even when they are also authors. All other authors belong in - * `authored`. The requester always has an author row, so this does not use `createdById`. - */ type InboxCategoryFilter = { userId: string; + // Assigned reviewers belong in waiting, even when they also authored the review. category: 'waiting' | 'authored'; }; @@ -43,7 +38,7 @@ type FindInboxRequestsOptions = { state?: WorkflowReviewRequestState; category?: InboxCategoryFilter; limit: number; - cursor?: InboxCursor; + boundary?: WorkflowReviewInboxBoundary; }; export type InboxStateCounts = { @@ -183,7 +178,7 @@ export class WorkflowReviewInboxRepository extends BaseRepository { - const { visibility, state, category, limit, cursor } = options; + const { visibility, state, category, limit, boundary } = options; const queryBuilder = this.createQueryBuilder('review') .orderBy('review.createdAt', 'DESC') .addOrderBy('review.id', 'ASC'); @@ -193,11 +188,13 @@ export class WorkflowReviewInboxRepository extends BaseRepository :id))', - { createdAt: cursor.createdAt, id: cursor.id }, + { createdAt: boundary.createdAt, id: boundary.id }, ); + } else if (boundary) { + queryBuilder.andWhere('review.createdAt < :createdAt', { createdAt: boundary.createdAt }); } queryBuilder.take(limit); diff --git a/packages/cli/src/modules/inbox/__tests__/inbox-source.registry.test.ts b/packages/cli/src/modules/inbox/__tests__/inbox-source.registry.test.ts new file mode 100644 index 00000000000..04d16867a36 --- /dev/null +++ b/packages/cli/src/modules/inbox/__tests__/inbox-source.registry.test.ts @@ -0,0 +1,17 @@ +import { UnexpectedError } from '@n8n/errors'; +import { mock } from 'vitest-mock-extended'; + +import { InboxSourceRegistry, type InboxSource } from '../inbox-source.registry'; + +describe('InboxSourceRegistry', () => { + it('rejects a duplicate without replacing the registered source', () => { + const registry = new InboxSourceRegistry(); + const source = mock({ type: 'workflow_review' }); + registry.register(source); + expect(() => registry.register(mock({ type: 'workflow_review' }))).toThrow( + UnexpectedError, + ); + expect(registry.find('workflow_review')).toBe(source); + expect(registry.types()).toEqual(['workflow_review']); + }); +}); diff --git a/packages/cli/src/modules/inbox/__tests__/inbox.controller.test.ts b/packages/cli/src/modules/inbox/__tests__/inbox.controller.test.ts new file mode 100644 index 00000000000..fb92224d52a --- /dev/null +++ b/packages/cli/src/modules/inbox/__tests__/inbox.controller.test.ts @@ -0,0 +1,37 @@ +import type { AuthenticatedRequest } from '@n8n/db'; +import type { Response } from 'express'; +import { mock } from 'vitest-mock-extended'; + +import { getRouteCases } from '@test/controller-route-metadata'; + +import { InboxController } from '../inbox.controller'; +import type { InboxService } from '../inbox.service'; + +describe('InboxController', () => { + const routes = getRouteCases(InboxController); + + it('exposes only the shared list and summary reads', () => { + expect(routes.map(({ handlerName }) => handlerName).sort()).toEqual(['getSummary', 'list']); + }); + + // Cross-project reads use each source's service authorization. + it.each(routes)( + '$handlerName requires authentication without a global workflow gate', + ({ route }) => { + expect(route.skipAuth).toBe(false); + expect(route.accessScope).toBeUndefined(); + expect(route.licenseFeature).toBeUndefined(); + }, + ); + + it('passes the authenticated user and validated query to the service', async () => { + const service = mock(); + const controller = new InboxController(service); + const request = mock(); + const query = { state: 'closed' as const, limit: 10 }; + await controller.list(request, mock(), query); + await controller.getSummary(request); + expect(service.list).toHaveBeenCalledWith(request.user, query); + expect(service.getSummary).toHaveBeenCalledWith(request.user); + }); +}); diff --git a/packages/cli/src/modules/inbox/__tests__/inbox.service.test.ts b/packages/cli/src/modules/inbox/__tests__/inbox.service.test.ts new file mode 100644 index 00000000000..623e2f20387 --- /dev/null +++ b/packages/cli/src/modules/inbox/__tests__/inbox.service.test.ts @@ -0,0 +1,354 @@ +import type { InboxItem, InboxSourceType, InboxWorkflowReviewItem } from '@n8n/api-types'; +import type { Logger } from '@n8n/backend-common'; +import type { User } from '@n8n/db'; +import { BadRequestError, ServiceUnavailableError } from '@n8n/errors'; +import { mock } from 'vitest-mock-extended'; + +import { InboxSourceRegistry, type InboxSourceQuery } from '../inbox-source.registry'; +import { InboxService } from '../inbox.service'; + +const timestamp = (hour: number) => new Date(Date.UTC(2026, 9, 7, hour)).toISOString(); + +function review(id: string, hour: number): InboxWorkflowReviewItem { + return { + type: 'workflow_review', + id, + state: 'open', + decision: 'pending', + projectId: 'project', + title: id, + workflowName: 'Workflow', + workflowVersionId: null, + requester: null, + authors: [], + reviewers: [], + createdAt: timestamp(hour), + updatedAt: timestamp(hour), + }; +} + +function createSource(type: InboxSourceType, rows: InboxItem[] = []) { + return { + type, + isEnabled: vi.fn(async () => true), + list: vi.fn(async (_user: User, { state, boundary, limit }: InboxSourceQuery) => + rows + .filter((item) => { + if (item.state !== state) return false; + if (!boundary) return true; + const difference = Date.parse(item.createdAt) - boundary.createdAt.getTime(); + return ( + difference < 0 || + (difference === 0 && boundary.mode === 'afterItem' && item.id > boundary.id) + ); + }) + .slice(0, limit), + ), + count: vi.fn(async (_user: User) => ({ + open: rows.filter(({ state }) => state === 'open').length, + closed: rows.filter(({ state }) => state === 'closed').length, + })), + }; +} + +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +describe('InboxService', () => { + const user = mock({ id: 'user' }); + let registry: InboxSourceRegistry; + let logger: ReturnType>; + let service: InboxService; + + beforeEach(() => { + registry = new InboxSourceRegistry(); + logger = mock(); + service = new InboxService(registry, logger); + }); + + it('pages through a source without skipping prefetched rows', async () => { + const reviews = createSource('workflow_review', [ + review('r12', 12), + review('r11', 11), + review('r10', 10), + review('r9', 9), + review('r8', 8), + review('r7', 7), + ]); + registry.register(reviews); + + const first = await service.list(user, { state: 'open', limit: 2 }); + const second = await service.list(user, { state: 'open', limit: 2, cursor: first.nextCursor! }); + const third = await service.list(user, { state: 'open', limit: 2, cursor: second.nextCursor! }); + + expect(first.data.map(({ id }) => id)).toEqual(['r12', 'r11']); + expect(second.data.map(({ id }) => id)).toEqual(['r10', 'r9']); + expect(third.data.map(({ id }) => id)).toEqual(['r8', 'r7']); + expect(third).toMatchObject({ hasMore: false, nextCursor: null, partial: false }); + expect(reviews.list).toHaveBeenNthCalledWith(2, user, { + state: 'open', + limit: 3, + boundary: { mode: 'afterItem', createdAt: new Date(timestamp(11)), id: 'r11' }, + }); + expect(reviews.count).not.toHaveBeenCalled(); + }); + + it.each(['waiting', 'authored'] as const)( + 'passes the %s category to Closed source reads', + async (category) => { + const source = createSource('workflow_review'); + registry.register(source); + await service.list(user, { state: 'closed', category, limit: 15 }); + expect(source.list).toHaveBeenCalledWith( + user, + expect.objectContaining({ state: 'closed', category }), + ); + }, + ); + + it('rejects cursors used with another category', async () => { + const source = createSource('workflow_review'); + registry.register(source); + const cursor = { + version: 1, + state: 'open', + category: 'authored', + after: { type: 'workflow_review', id: 'r12', createdAt: timestamp(12) }, + activeSources: ['workflow_review'], + failedSources: [], + }; + for (const category of [undefined, 'waiting'] as const) { + await expect( + service.list(user, { state: 'open', category, limit: 15, cursor: encode(cursor) }), + ).rejects.toBeInstanceOf(BadRequestError); + } + await expect( + service.list(user, { + state: 'open', + category: 'waiting', + limit: 15, + cursor: encode({ ...cursor, category: undefined }), + }), + ).rejects.toBeInstanceOf(BadRequestError); + expect(source.isEnabled).not.toHaveBeenCalled(); + }); + + it.each([1, 3])( + 'continues tied timestamps within the cursor source with page size %s', + async (limit) => { + registry.register(createSource('workflow_review', [review('1', 12), review('2', 12)])); + const keys: string[] = []; + let cursor: string | undefined; + do { + const page = await service.list(user, { state: 'open', limit, cursor }); + keys.push(...page.data.map(({ type, id }) => `${type}:${id}`)); + cursor = page.nextCursor ?? undefined; + } while (cursor); + expect(keys).toEqual(['workflow_review:1', 'workflow_review:2']); + }, + ); + + it('preserves the source database order when IDs use a different collation', async () => { + registry.register(createSource('workflow_review', [review('a', 12), review('B', 12)])); + const page = await service.list(user, { state: 'open', limit: 3 }); + expect(page.data.map(({ type, id }) => `${type}:${id}`)).toEqual([ + 'workflow_review:a', + 'workflow_review:B', + ]); + }); + + it('continues after the anchor row is deleted', async () => { + const rows = [review('r12', 12), review('r10', 10), review('r8', 8)]; + registry.register(createSource('workflow_review', rows)); + const first = await service.list(user, { state: 'open', limit: 1 }); + rows.shift(); + const next = await service.list(user, { state: 'open', limit: 1, cursor: first.nextCursor! }); + expect(next.data.map(({ id }) => id)).toEqual(['r10']); + }); + + it('passes the current user and tab to each source', async () => { + const closed = { ...review('closed', 11), state: 'closed' as const }; + const source = createSource('workflow_review', [review('open', 12), closed]); + registry.register(source); + const page = await service.list(user, { state: 'closed', limit: 15 }); + expect(page.data).toEqual([closed]); + expect(source.list).toHaveBeenCalledWith(user, { + state: 'closed', + limit: 16, + boundary: undefined, + }); + }); + + it('returns a retryable error with source metadata when all reads fail', async () => { + for (const type of ['workflow_review'] as const) { + const source = createSource(type); + source.list.mockRejectedValue(new Error('Private query details')); + registry.register(source); + } + const request = service.list(user, { state: 'open', limit: 15 }); + await expect(request).rejects.toBeInstanceOf(ServiceUnavailableError); + await expect(request).rejects.toMatchObject({ + httpStatusCode: 503, + message: 'Inbox is temporarily unavailable', + meta: { + partial: true, + failedSources: ['workflow_review'], + disabledSources: [], + }, + }); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('reports a disabled source without reading it', async () => { + const source = createSource('workflow_review', [review('r12', 12)]); + source.isEnabled.mockResolvedValue(false); + registry.register(source); + await expect(service.list(user, { state: 'open', limit: 15 })).resolves.toEqual({ + data: [], + hasMore: false, + nextCursor: null, + partial: false, + failedSources: [], + disabledSources: ['workflow_review'], + }); + expect(source.list).not.toHaveBeenCalled(); + }); + + it('checks current availability on later pages and removes a disabled source', async () => { + const reviews = createSource('workflow_review', [review('r12', 12), review('r10', 10)]); + registry.register(reviews); + const first = await service.list(user, { state: 'open', limit: 1 }); + reviews.isEnabled.mockResolvedValue(false); + const second = await service.list(user, { state: 'open', limit: 1, cursor: first.nextCursor! }); + expect(second.data.map(({ id }) => id)).toEqual([]); + expect(second.disabledSources).toEqual(['workflow_review']); + expect(reviews.list).toHaveBeenCalledTimes(1); + }); + + it('treats a recognized source absent after restart as disabled', async () => { + const cursor = encode({ + version: 1, + state: 'open', + after: { type: 'workflow_review', id: 'r11', createdAt: timestamp(11) }, + activeSources: ['workflow_review'], + failedSources: [], + }); + const page = await service.list(user, { state: 'open', limit: 15, cursor }); + expect(page.data.map(({ id }) => id)).toEqual([]); + expect(page.disabledSources).toEqual(['workflow_review']); + }); + + describe('cursor validation', () => { + const validCursor = { + version: 1, + state: 'open', + after: { type: 'workflow_review', id: 'row', createdAt: timestamp(12) }, + activeSources: ['workflow_review'], + failedSources: [], + }; + it.each([ + ['invalid encoding', '!!!'], + ['invalid JSON', Buffer.from('not JSON').toString('base64url')], + ['large cursor', 'x'.repeat(2049)], + ['wrong version', encode({ ...validCursor, version: 2 })], + ['wrong tab', encode({ ...validCursor, state: 'closed' })], + ['unknown source', encode({ ...validCursor, activeSources: ['unknown'] })], + [ + 'duplicate source', + encode({ ...validCursor, activeSources: ['workflow_review', 'workflow_review'] }), + ], + ['overlapping source', encode({ ...validCursor, failedSources: ['workflow_review'] })], + ['empty source set', encode({ ...validCursor, activeSources: [] })], + ['empty anchor ID', encode({ ...validCursor, after: { ...validCursor.after, id: '' } })], + [ + 'invalid timestamp', + encode({ ...validCursor, after: { ...validCursor.after, createdAt: 'yesterday' } }), + ], + ])('rejects %s before reading sources', async (_name, cursor) => { + const source = createSource('workflow_review'); + registry.register(source); + await expect(service.list(user, { state: 'open', limit: 15, cursor })).rejects.toBeInstanceOf( + BadRequestError, + ); + expect(source.isEnabled).not.toHaveBeenCalled(); + }); + }); + + describe('summary', () => { + it('adds authorized counts without listing rows', async () => { + const reviews = createSource('workflow_review', [ + review('r12', 12), + { ...review('r11', 11), state: 'closed' }, + ]); + registry.register(reviews); + await expect(service.getSummary(user)).resolves.toEqual({ + counts: { open: 1, closed: 1 }, + partial: false, + failedSources: [], + disabledSources: [], + }); + expect(reviews.count).toHaveBeenCalledWith(user); + expect(reviews.list).not.toHaveBeenCalled(); + }); + + it('returns an error when every count fails', async () => { + const source = createSource('workflow_review'); + source.count.mockRejectedValue(new Error('Count failed')); + registry.register(source); + await expect(service.getSummary(user)).rejects.toBeInstanceOf(ServiceUnavailableError); + }); + }); + + describe('settings', () => { + it('keeps an available empty Inbox visible without reading rows or counts', async () => { + const source = createSource('workflow_review'); + registry.register(source); + await expect(service.getSettings()).resolves.toEqual({ + enabled: true, + availableTypes: ['workflow_review'], + failedTypes: [], + }); + expect(source.list).not.toHaveBeenCalled(); + expect(source.count).not.toHaveBeenCalled(); + }); + + it('reflects changes to an already registered source', async () => { + const source = createSource('workflow_review'); + registry.register(source); + source.isEnabled.mockResolvedValue(false); + await expect(service.getSettings()).resolves.toEqual({ + enabled: false, + availableTypes: [], + failedTypes: [], + }); + source.isEnabled.mockResolvedValue(true); + await expect(service.getSettings()).resolves.toEqual({ + enabled: true, + availableTypes: ['workflow_review'], + failedTypes: [], + }); + }); + + it('keeps a retry path when availability is unknown', async () => { + const source = createSource('workflow_review'); + source.isEnabled.mockRejectedValue(new Error('Availability failed')); + registry.register(source); + await expect(service.getSettings()).resolves.toEqual({ + enabled: true, + availableTypes: [], + failedTypes: ['workflow_review'], + }); + }); + + it('disables Inbox when no sources are registered', async () => { + await expect(service.getSettings()).resolves.toEqual({ + enabled: false, + availableTypes: [], + failedTypes: [], + }); + await expect(service.getSummary(user)).resolves.toMatchObject({ + counts: { open: 0, closed: 0 }, + partial: false, + }); + }); + }); +}); diff --git a/packages/cli/src/modules/inbox/inbox-source.registry.ts b/packages/cli/src/modules/inbox/inbox-source.registry.ts new file mode 100644 index 00000000000..a7e9d372ccd --- /dev/null +++ b/packages/cli/src/modules/inbox/inbox-source.registry.ts @@ -0,0 +1,56 @@ +import type { + InboxCategory, + InboxCounts, + InboxItem, + InboxSourceType, + InboxState, +} from '@n8n/api-types'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { UnexpectedError } from '@n8n/errors'; + +export type InboxSourceBoundary = + // Include only earlier timestamps. + | { mode: 'beforeTime'; createdAt: Date } + // Include earlier timestamps, or equal timestamps with a greater ID in database order. + | { mode: 'afterItem'; createdAt: Date; id: string }; + +export type InboxSourceQuery = { + state: InboxState; + category?: InboxCategory; + limit: number; + boundary?: InboxSourceBoundary; +}; + +export interface InboxSource { + type: InboxSourceType; + // Return false when configuration or licensing excludes the source. Reject if the check fails. + isEnabled(): Promise; + // Apply access, state, and category filters before the boundary and limit. + // Return createdAt DESC, then id ASC in database order. + list(user: User, query: InboxSourceQuery): Promise; + // Use the same access and state rules as list, without a boundary or limit. + count(user: User): Promise; +} + +@Service() +export class InboxSourceRegistry { + private readonly sources = new Map(); + + register(source: InboxSource) { + if (this.sources.has(source.type)) { + throw new UnexpectedError('Inbox source is already registered', { + extra: { type: source.type }, + }); + } + this.sources.set(source.type, source); + } + + find(type: InboxSourceType) { + return this.sources.get(type); + } + + types() { + return [...this.sources.keys()]; + } +} diff --git a/packages/cli/src/modules/inbox/inbox.controller.ts b/packages/cli/src/modules/inbox/inbox.controller.ts new file mode 100644 index 00000000000..fa5e325d3af --- /dev/null +++ b/packages/cli/src/modules/inbox/inbox.controller.ts @@ -0,0 +1,22 @@ +import { ListInboxQueryDto } from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { Get, Query, RestController } from '@n8n/decorators'; +import type { Response } from 'express'; + +import { InboxService } from './inbox.service'; + +@RestController('/inbox') +export class InboxController { + constructor(private readonly inboxService: InboxService) {} + + // Each source enforces access. A global workflow scope would exclude project members. + @Get('/') + async list(req: AuthenticatedRequest, _res: Response, @Query query: ListInboxQueryDto) { + return await this.inboxService.list(req.user, query); + } + + @Get('/summary') + async getSummary(req: AuthenticatedRequest) { + return await this.inboxService.getSummary(req.user); + } +} diff --git a/packages/cli/src/modules/inbox/inbox.module.ts b/packages/cli/src/modules/inbox/inbox.module.ts new file mode 100644 index 00000000000..57cff8ad8ea --- /dev/null +++ b/packages/cli/src/modules/inbox/inbox.module.ts @@ -0,0 +1,8 @@ +import { BackendModule, type ModuleInterface } from '@n8n/decorators'; + +@BackendModule({ name: 'inbox', instanceTypes: ['main'] }) +export class InboxModule implements ModuleInterface { + async init() { + await import('./inbox.controller.js'); + } +} diff --git a/packages/cli/src/modules/inbox/inbox.service.ts b/packages/cli/src/modules/inbox/inbox.service.ts new file mode 100644 index 00000000000..f11fd7938cb --- /dev/null +++ b/packages/cli/src/modules/inbox/inbox.service.ts @@ -0,0 +1,241 @@ +import { + inboxCategorySchema, + inboxSourceTypeSchema, + inboxStateSchema, + type GetInboxSummaryResponse, + type InboxCategory, + type InboxItem, + type InboxSettings, + type InboxSourceStatus, + type InboxSourceType, + type InboxState, + type ListInboxQueryDto, + type ListInboxResponse, +} from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { BadRequestError, ServiceUnavailableError } from '@n8n/errors'; +import { z } from 'zod'; + +import { + InboxSourceRegistry, + type InboxSource, + type InboxSourceBoundary, +} from './inbox-source.registry'; + +const sourceOrder: InboxSourceType[] = ['workflow_review']; + +const cursorSchema = z + .object({ + version: z.literal(1), + state: inboxStateSchema, + category: inboxCategorySchema.optional(), + after: z.object({ + createdAt: z.string().datetime(), + type: inboxSourceTypeSchema, + id: z.string().min(1).max(128), + }), + activeSources: z.array(inboxSourceTypeSchema).min(1).max(sourceOrder.length), + failedSources: z.array(inboxSourceTypeSchema).max(sourceOrder.length), + }) + .strict(); + +type InboxCursor = z.infer; + +type SourceReads = { + successful: Array<{ type: InboxSourceType; value: T }>; + failedSources: InboxSourceType[]; + disabledSources: InboxSourceType[]; +}; + +class InboxUnavailableError extends ServiceUnavailableError { + override readonly meta: Record; + + constructor(status: InboxSourceStatus) { + super('Inbox is temporarily unavailable'); + this.meta = { ...status }; + } +} + +@Service() +export class InboxService { + constructor( + private readonly registry: InboxSourceRegistry, + private readonly logger: Logger, + ) {} + + async list(user: User, query: ListInboxQueryDto): Promise { + const sourceTypes = this.sourcesForCategory(query.category); + const cursor = query.cursor + ? this.decodeCursor(query.cursor, query.state, query.category, sourceTypes) + : undefined; + const reads = await this.readAvailableSources( + cursor?.activeSources ?? this.registry.types().filter((type) => sourceTypes.includes(type)), + async (source) => + await source.list(user, { + state: query.state, + ...(query.category !== undefined ? { category: query.category } : {}), + limit: query.limit + 1, + boundary: this.boundaryFor(source.type, cursor?.after), + }), + ); + const failedSources = [...new Set([...(cursor?.failedSources ?? []), ...reads.failedSources])]; + const status: InboxSourceStatus = { + partial: failedSources.length > 0, + failedSources, + disabledSources: reads.disabledSources, + }; + if (reads.successful.length === 0 && status.partial) { + throw new InboxUnavailableError(status); + } + + const rows = this.mergeRows( + reads.successful.map(({ value }) => value), + query.limit + 1, + ); + const data = rows.slice(0, query.limit); + const hasMore = rows.length > query.limit; + const last = data.at(-1); + + return { + ...status, + data, + hasMore, + nextCursor: + hasMore && last + ? this.encodeCursor({ + version: 1, + state: query.state, + ...(query.category !== undefined ? { category: query.category } : {}), + after: { createdAt: last.createdAt, type: last.type, id: last.id }, + activeSources: reads.successful.map(({ type }) => type), + failedSources, + }) + : null, + }; + } + + async getSummary(user: User): Promise { + const reads = await this.readAvailableSources( + this.registry.types(), + async (source) => await source.count(user), + ); + const status: InboxSourceStatus = { + partial: reads.failedSources.length > 0, + failedSources: reads.failedSources, + disabledSources: reads.disabledSources, + }; + if (reads.successful.length === 0 && status.partial) { + throw new InboxUnavailableError(status); + } + + return { + ...status, + counts: status.partial + ? null + : reads.successful.reduce( + (counts, { value }) => ({ + open: counts.open + value.open, + closed: counts.closed + value.closed, + }), + { open: 0, closed: 0 }, + ), + }; + } + + async getSettings(): Promise { + const reads = await this.readAvailableSources(this.registry.types(), async () => true); + return { + enabled: reads.successful.length > 0 || reads.failedSources.length > 0, + availableTypes: reads.successful.map(({ type }) => type), + failedTypes: reads.failedSources, + }; + } + + private async readAvailableSources( + types: InboxSourceType[], + operation: (source: InboxSource) => Promise, + ): Promise> { + const settled = await Promise.allSettled( + types.map(async (type): Promise<{ disabled: true } | { disabled: false; value: T }> => { + const source = this.registry.find(type); + if (!source || !(await source.isEnabled())) return { disabled: true }; + return { disabled: false, value: await operation(source) }; + }), + ); + const reads: SourceReads = { successful: [], failedSources: [], disabledSources: [] }; + for (const [index, type] of types.entries()) { + const result = settled[index]; + if (result.status === 'rejected') { + this.logger.warn('Inbox source read failed', { type, error: result.reason }); + reads.failedSources.push(type); + } else if (result.value.disabled) { + reads.disabledSources.push(type); + } else { + reads.successful.push({ type, value: result.value.value }); + } + } + return reads; + } + + private boundaryFor( + type: InboxSourceType, + after: InboxCursor['after'] | undefined, + ): InboxSourceBoundary | undefined { + if (!after) return undefined; + const createdAt = new Date(after.createdAt); + if (type === after.type) return { mode: 'afterItem', createdAt, id: after.id }; + // Other sources resume at older timestamps. Equal timestamps can be skipped. + return { mode: 'beforeTime', createdAt }; + } + + private mergeRows(pages: InboxItem[][], limit: number): InboxItem[] { + // Stable sorting preserves each source's database order for equal timestamps. + return pages + .flat() + .sort( + (left, right) => + Date.parse(right.createdAt) - Date.parse(left.createdAt) || + sourceOrder.indexOf(left.type) - sourceOrder.indexOf(right.type), + ) + .slice(0, limit); + } + + private encodeCursor(cursor: InboxCursor): string { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); + } + + private sourcesForCategory(category?: InboxCategory): InboxSourceType[] { + return category === 'authored' ? ['workflow_review'] : sourceOrder; + } + + private decodeCursor( + value: string, + state: InboxState, + category: InboxCategory | undefined, + sourceTypes: InboxSourceType[], + ): InboxCursor { + try { + if (value.length > 2048 || !/^[\w-]+$/.test(value)) { + throw new BadRequestError('Invalid Inbox pagination cursor'); + } + const cursor = cursorSchema.parse( + JSON.parse(Buffer.from(value, 'base64url').toString('utf8')), + ); + const sources = [...cursor.activeSources, ...cursor.failedSources]; + if ( + cursor.state !== state || + cursor.category !== category || + sources.some((type) => !sourceTypes.includes(type)) || + new Set(sources).size !== sources.length || + !cursor.activeSources.includes(cursor.after.type) + ) { + throw new BadRequestError('Invalid Inbox pagination cursor'); + } + return cursor; + } catch { + throw new BadRequestError('Invalid Inbox pagination cursor'); + } + } +} diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-detail.controller.integration.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-detail.controller.integration.test.ts index 029b51031b2..1ad540c1aff 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-detail.controller.integration.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-detail.controller.integration.test.ts @@ -34,7 +34,7 @@ const workflowValidationService = mockInstance(WorkflowValidationService); const testServer = utils.setupTestServer({ endpointGroups: ['workflow-reviews', 'workflows'], enabledFeatures: ['feat:workflowReviews'], - modules: ['workflow-reviews'], + modules: ['workflow-reviews', 'inbox'], }); let owner: User; @@ -485,8 +485,8 @@ describe('GET /workflow-review-requests/:workflowReviewRequestId', () => { const workflow = await createWorkflow({}, teamProject); await seedRequest(workflow.id, null, owner); - await ownerAgent.get('/workflow-review-requests/inbox').expect(200); - await ownerAgent.get('/workflow-review-requests/summary').expect(200); + await ownerAgent.get('/inbox').expect(200); + await ownerAgent.get('/inbox/summary').expect(200); // 400 (missing workflowId), not 404 — proves it still reaches its own handler await ownerAgent.get('/workflow-review-requests/eligible-reviewers').expect(400); }); diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.controller.integration.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.controller.integration.test.ts index 60d06e5b0d1..59251fffa30 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.controller.integration.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.controller.integration.test.ts @@ -39,7 +39,7 @@ const workflowValidationService = mockInstance(WorkflowValidationService); const testServer = utils.setupTestServer({ endpointGroups: ['workflow-reviews', 'workflows'], enabledFeatures: ['feat:workflowReviews'], - modules: ['workflow-reviews'], + modules: ['workflow-reviews', 'inbox'], }); let owner: User; @@ -145,29 +145,29 @@ async function seedOrphanedOpenReview() { return orphan; } -describe('GET /workflow-review-requests/summary', () => { +describe('GET /inbox/summary', () => { test('counts open and closed reviews for the instance owner', async () => { await seedInboxRequests(); - const response = await ownerAgent.get('/workflow-review-requests/summary').expect(200); + const response = await ownerAgent.get('/inbox/summary').expect(200); - expect(response.body.data).toEqual({ open: 1, closed: 1 }); + expect(response.body.data.counts).toEqual({ open: 1, closed: 1 }); }); test('counts the reviews an assigned reviewer was asked to look at', async () => { await seedInboxRequests(); - const response = await memberAgent.get('/workflow-review-requests/summary').expect(200); + const response = await memberAgent.get('/inbox/summary').expect(200); - expect(response.body.data).toEqual({ open: 1, closed: 1 }); + expect(response.body.data.counts).toEqual({ open: 1, closed: 1 }); }); test('counts nothing for an uninvolved project member', async () => { await seedInboxRequests(); - const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200); + const response = await viewerAgent.get('/inbox/summary').expect(200); - expect(response.body.data).toEqual({ open: 0, closed: 0 }); + expect(response.body.data.counts).toEqual({ open: 0, closed: 0 }); }); test('counts a requester their own review even without publish scope', async () => { @@ -178,9 +178,9 @@ describe('GET /workflow-review-requests/summary', () => { }); await linkToNewWorkflow(ownRequest.id); - const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200); + const response = await viewerAgent.get('/inbox/summary').expect(200); - expect(response.body.data).toEqual({ open: 1, closed: 0 }); + expect(response.body.data.counts).toEqual({ open: 1, closed: 0 }); }); test('still counts an open review orphaned by a workflow hard delete until a sweep closes it', async () => { @@ -188,28 +188,29 @@ describe('GET /workflow-review-requests/summary', () => { await seedOrphanedOpenReview(); // Owner exercises the whole-inbox scope, member the involvement filter - const ownerResponse = await ownerAgent.get('/workflow-review-requests/summary').expect(200); - expect(ownerResponse.body.data).toEqual({ open: 2, closed: 1 }); + const ownerResponse = await ownerAgent.get('/inbox/summary').expect(200); + expect(ownerResponse.body.data.counts).toEqual({ open: 2, closed: 1 }); - const memberResponse = await memberAgent.get('/workflow-review-requests/summary').expect(200); - expect(memberResponse.body.data).toEqual({ open: 2, closed: 1 }); + const memberResponse = await memberAgent.get('/inbox/summary').expect(200); + expect(memberResponse.body.data.counts).toEqual({ open: 2, closed: 1 }); }); - test('refuses the counts once an admin turns reviews off', async () => { + test('omits review counts once an admin turns reviews off', async () => { await policyService.set(false); - await ownerAgent.get('/workflow-review-requests/summary').expect(403); + const response = await ownerAgent.get('/inbox/summary').expect(200); + expect(response.body.data).toMatchObject({ + counts: { open: 0, closed: 0 }, + disabledSources: ['workflow_review'], + }); }); }); -describe('GET /workflow-review-requests/inbox', () => { +describe('GET /inbox', () => { test('shows the instance owner every open review', async () => { const { openRequest, openWorkflow } = await seedInboxRequests(); - const response = await ownerAgent - .get('/workflow-review-requests/inbox') - .query({ state: 'open', limit: 15 }) - .expect(200); + const response = await ownerAgent.get('/inbox').query({ state: 'open', limit: 15 }).expect(200); expect(response.body.data.data).toHaveLength(1); expect(response.body.data.data[0]).toMatchObject({ @@ -226,7 +227,7 @@ describe('GET /workflow-review-requests/inbox', () => { test('shows nothing to an uninvolved project member', async () => { await seedInboxRequests(); - const response = await viewerAgent.get('/workflow-review-requests/inbox').expect(200); + const response = await viewerAgent.get('/inbox').expect(200); expect(response.body.data.data).toEqual([]); expect(response.body.data.hasMore).toBe(false); @@ -238,7 +239,7 @@ describe('GET /workflow-review-requests/inbox', () => { // Owner exercises the whole-inbox scope, member the involvement filter const ownerResponse = await ownerAgent - .get('/workflow-review-requests/inbox') + .get('/inbox') .query({ state: 'open', limit: 15 }) .expect(200); expect(ownerResponse.body.data.data.map((row: { id: string }) => row.id).sort()).toEqual( @@ -246,7 +247,7 @@ describe('GET /workflow-review-requests/inbox', () => { ); const memberResponse = await memberAgent - .get('/workflow-review-requests/inbox') + .get('/inbox') .query({ state: 'open', limit: 15 }) .expect(200); expect(memberResponse.body.data.data.map((row: { id: string }) => row.id).sort()).toEqual( @@ -258,7 +259,7 @@ describe('GET /workflow-review-requests/inbox', () => { const { closedRequest } = await seedInboxRequests(); const response = await ownerAgent - .get('/workflow-review-requests/inbox') + .get('/inbox') .query({ state: 'closed', limit: 15 }) .expect(200); @@ -268,10 +269,11 @@ describe('GET /workflow-review-requests/inbox', () => { ]); }); - test('refuses everything once an admin turns reviews off', async () => { + test('omits review rows once an admin turns reviews off', async () => { await policyService.set(false); - await ownerAgent.get('/workflow-review-requests/inbox').expect(403); + const response = await ownerAgent.get('/inbox').expect(200); + expect(response.body.data).toMatchObject({ data: [], disabledSources: ['workflow_review'] }); }); test('pages through the inbox with a cursor', async () => { @@ -287,17 +289,14 @@ describe('GET /workflow-review-requests/inbox', () => { ); await linkToNewWorkflow(secondRequest.id); - const firstPage = await ownerAgent - .get('/workflow-review-requests/inbox') - .query({ state: 'open', limit: 1 }) - .expect(200); + const firstPage = await ownerAgent.get('/inbox').query({ state: 'open', limit: 1 }).expect(200); expect(firstPage.body.data.data).toHaveLength(1); expect(firstPage.body.data.hasMore).toBe(true); expect(firstPage.body.data.nextCursor).toBeTruthy(); const secondPage = await ownerAgent - .get('/workflow-review-requests/inbox') + .get('/inbox') .query({ state: 'open', limit: 1, @@ -327,10 +326,10 @@ describe('GET /workflow-review-requests/inbox', () => { {}, ); - const memberResponse = await memberAgent.get('/workflow-review-requests/inbox').expect(200); + const memberResponse = await memberAgent.get('/inbox').expect(200); expect(memberResponse.body.data.data).toEqual([]); - const ownerResponse = await ownerAgent.get('/workflow-review-requests/inbox').expect(200); + const ownerResponse = await ownerAgent.get('/inbox').expect(200); expect(ownerResponse.body.data.data).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -353,7 +352,7 @@ describe('GET /workflow-review-requests/inbox', () => { ); await linkToNewWorkflow(ownRequest.id, otherProject); - const response = await memberAgent.get('/workflow-review-requests/inbox').expect(200); + const response = await memberAgent.get('/inbox').expect(200); expect(response.body.data.data).toEqual([]); }); @@ -363,10 +362,7 @@ describe('GET /workflow-review-requests/inbox', () => { await linkUserToProject(projectAdmin, teamProject, 'project:admin'); const { openRequest } = await seedInboxRequests(); - const response = await testServer - .authAgentFor(projectAdmin) - .get('/workflow-review-requests/inbox') - .expect(200); + const response = await testServer.authAgentFor(projectAdmin).get('/inbox').expect(200); expect(response.body.data.data).toEqual( expect.arrayContaining([expect.objectContaining({ id: openRequest.id })]), @@ -386,10 +382,7 @@ describe('GET /workflow-review-requests/inbox', () => { ); await linkToNewWorkflow(secondRequest.id); - const firstPage = await ownerAgent - .get('/workflow-review-requests/inbox') - .query({ state: 'open', limit: 1 }) - .expect(200); + const firstPage = await ownerAgent.get('/inbox').query({ state: 'open', limit: 1 }).expect(200); const cursor = firstPage.body.data.nextCursor as string; const firstId = firstPage.body.data.data[0].id as string; @@ -397,7 +390,7 @@ describe('GET /workflow-review-requests/inbox', () => { await requestRepository.delete({ id: firstId }); const secondPage = await ownerAgent - .get('/workflow-review-requests/inbox') + .get('/inbox') .query({ state: 'open', limit: 1, cursor }) .expect(200); @@ -415,10 +408,7 @@ describe('GET /workflow-review-requests/inbox', () => { }); await linkToNewWorkflow(request.id); - const response = await ownerAgent - .get('/workflow-review-requests/inbox') - .query({ state: 'open', limit: 15 }) - .expect(200); + const response = await ownerAgent.get('/inbox').query({ state: 'open', limit: 15 }).expect(200); const item = response.body.data.data.find((row: { id: string }) => row.id === request.id); expect(item.requester).toEqual({ @@ -470,10 +460,7 @@ describe('GET /workflow-review-requests/inbox', () => { await userRepository.delete({ id: departedCreator.id }); await userRepository.delete({ id: departedReviewer.id }); - const response = await ownerAgent - .get('/workflow-review-requests/inbox') - .query({ state: 'open', limit: 15 }) - .expect(200); + const response = await ownerAgent.get('/inbox').query({ state: 'open', limit: 15 }).expect(200); const rows = response.body.data.data as Array<{ id: string; @@ -496,6 +483,49 @@ describe('GET /workflow-review-requests/inbox', () => { expect(withoutCreator.reviewers).toEqual([]); }); + test('includes authored and assigned reviews in one list', async () => { + const mine = await seedReview({ projectId: teamProject.id, author: member, title: 'Mine' }); + const theirs = await seedReview({ + projectId: teamProject.id, + author: owner, + title: 'Assigned', + }); + await reviewerRepository.addReviewers( + { workflowReviewRequestId: theirs.id, userIds: [member.id] }, + {}, + ); + const response = await memberAgent.get('/inbox').expect(200); + expect(response.body.data.data.map((row: { id: string }) => row.id).sort()).toEqual( + [mine.id, theirs.id].sort(), + ); + }); + + test('keeps co-authored reviews visible', async () => { + const request = await seedReview({ + projectId: teamProject.id, + author: owner, + title: 'Co-authored', + }); + await authorRepository.addAuthor( + { workflowReviewRequestId: request.id, userId: member.id }, + {}, + ); + const response = await memberAgent.get('/inbox').expect(200); + expect(response.body.data.data).toEqual([expect.objectContaining({ id: request.id })]); + }); + + test('shows a requester the review for a workflow shared only with them', async () => { + const sharedWorkflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(sharedWorkflow, [member]); + const request = await seedReview({ + projectId: ownerProject.id, + workflowId: sharedWorkflow.id, + author: member, + title: 'Shared with me', + }); + const response = await memberAgent.get('/inbox').expect(200); + expect(response.body.data.data).toEqual([expect.objectContaining({ id: request.id })]); + }); describe('category filter', () => { /** Mirrors the create endpoint: a requester always gets an author row too. */ async function openReviewBy( @@ -521,7 +551,7 @@ describe('GET /workflow-review-requests/inbox', () => { query: Record, ): Promise<{ ids: string[]; hasMore: boolean; nextCursor: string | null }> { const response = await agent - .get('/workflow-review-requests/inbox') + .get('/inbox') .query({ state: 'open', limit: 15, ...query }) .expect(200); return { @@ -701,12 +731,14 @@ describe('GET /workflow-review-requests/inbox', () => { expect(authoredPage.nextCursor).toBeNull(); }); - // The category filter ignores `state`, even though the editor sends it only - // for open reviews. test('filters closed reviews by category too', async () => { const closedMine = await openReviewBy(member.id, 'Closed mine', 'closed'); - await openReviewBy(owner.id, 'Closed theirs', 'closed'); + const closedTheirs = await openReviewBy(owner.id, 'Closed theirs', 'closed'); + await assignMember(closedTheirs.id); + expect((await inbox(memberAgent, { state: 'closed', category: 'waiting' })).ids).toEqual([ + closedTheirs.id, + ]); expect((await inbox(memberAgent, { state: 'closed', category: 'authored' })).ids).toEqual([ closedMine.id, ]); diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.list.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.list.service.test.ts index b90c4df8789..4a675db228f 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.list.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.list.service.test.ts @@ -65,120 +65,63 @@ describe('WorkflowReviewInboxService.listForInbox', () => { authorizationService.resolveInboxVisibility.mockResolvedValueOnce(visibility); } - it('returns paginated data with hasMore and nextCursor', async () => { + it('returns ordered source rows without slicing or creating a public cursor', async () => { mockVisibility(); - const rows = [ + const rows = ['second', 'first'].map((id) => mock({ - id: 'req-2', + id, projectId: 'proj-1', - title: 'Second', - decision: 'pending', + title: id, state: 'open', - createdAt: new Date('2024-01-02T00:00:00.000Z'), - updatedAt: new Date('2024-01-02T00:00:00.000Z'), - }), - mock({ - id: 'req-1', - projectId: 'proj-1', - title: 'First', decision: 'pending', - state: 'open', - createdAt: new Date('2024-01-01T00:00:00.000Z'), - updatedAt: new Date('2024-01-01T00:00:00.000Z'), + createdAt: new Date('2026-10-07T00:00:00.000Z'), + updatedAt: new Date('2026-10-07T00:00:00.000Z'), }), - ]; + ); workflowReviewInboxRepository.findRequests.mockResolvedValue(rows); workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds.mockResolvedValue( - new Map([['req-2', { workflowName: 'Linked workflow', workflowVersionId: 'ver-2' }]]), + new Map([['second', { workflowName: 'Linked workflow', workflowVersionId: 'ver-2' }]]), ); - const result = await service.listForInbox(user, { limit: 1 }); + const result = await service.listForInbox(user, { state: 'open', limit: 2 }); expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith({ visibility: involvedVisibility, state: 'open', + category: undefined, limit: 2, - cursor: undefined, + boundary: undefined, }); - expect(result.data).toHaveLength(1); - expect(result.data[0]?.workflowName).toBe('Linked workflow'); - expect(result.data[0]?.workflowVersionId).toBe('ver-2'); - expect(result.hasMore).toBe(true); - // nextCursor encodes the last row's keyset boundary (createdAt + id). - const expectedCursor = Buffer.from('2024-01-02T00:00:00.000Z|req-2', 'utf8').toString( - 'base64url', - ); - expect(result.nextCursor).toBe(expectedCursor); - // Participants are only resolved for the page, never for the lookahead row. - expect(participantResolver.resolve).toHaveBeenCalledWith([rows[0]]); + expect(result.map((row) => row.id)).toEqual(['second', 'first']); + expect(result[0]).toMatchObject({ type: 'workflow_review', workflowName: 'Linked workflow' }); + expect(participantResolver.resolve).toHaveBeenCalledWith(rows); }); - it('decodes the incoming cursor into a keyset boundary', async () => { + it.each([ + { mode: 'beforeTime', createdAt: new Date('2026-10-07T00:00:00.000Z') }, + { mode: 'afterItem', createdAt: new Date('2026-10-07T00:00:00.000Z'), id: 'last' }, + ] as const)('passes the $mode boundary to its repository', async (boundary) => { mockVisibility(); workflowReviewInboxRepository.findRequests.mockResolvedValue([]); workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds.mockResolvedValue( new Map(), ); - const cursor = Buffer.from('2024-01-02T00:00:00.000Z|req-2', 'utf8').toString('base64url'); - await service.listForInbox(user, { limit: 15, cursor }); + await service.listForInbox(user, { state: 'closed', limit: 16, boundary }); - expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith( - expect.objectContaining({ - cursor: { createdAt: new Date('2024-01-02T00:00:00.000Z'), id: 'req-2' }, - }), - ); + expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith({ + visibility: involvedVisibility, + state: 'closed', + category: undefined, + limit: 16, + boundary, + }); }); - it('rejects a malformed cursor', async () => { - mockVisibility(); - const cursor = Buffer.from('not-a-valid-cursor', 'utf8').toString('base64url'); - - await expect(service.listForInbox(user, { limit: 15, cursor })).rejects.toThrow( - 'Invalid pagination cursor', - ); - }); - - describe('category', () => { - beforeEach(() => { - workflowReviewInboxRepository.findRequests.mockResolvedValue([]); - workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds.mockResolvedValue( - new Map(), - ); - }); - - it.each(['authored', 'waiting'] as const)( - 'passes category %s through with the requesting user', - async (category) => { - mockVisibility(); - - await service.listForInbox(user, { limit: 15, category }); - - expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith( - expect.objectContaining({ category: { userId: 'user-1', category } }), - ); - }, - ); - - it('derives the user from the request, never from the query', async () => { - mockVisibility(); - const otherUser = mock({ id: 'user-2', role: { slug: 'global:member', scopes: [] } }); - - await service.listForInbox(otherUser, { limit: 15, category: 'authored' }); - - expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith( - expect.objectContaining({ category: { userId: 'user-2', category: 'authored' } }), - ); - }); - - it('leaves the query unfiltered when the category is omitted', async () => { - mockVisibility(); - - await service.listForInbox(user, { limit: 15 }); - - expect(workflowReviewInboxRepository.findRequests).toHaveBeenCalledWith( - expect.objectContaining({ category: undefined }), - ); - }); + it('checks the live policy before reading rows', async () => { + workflowReviewPolicyService.get.mockResolvedValue({ enabled: false }); + expect(await service.isInboxAvailable()).toBe(false); + await expect(service.listForInbox(user, { state: 'open', limit: 10 })).rejects.toThrow(); + expect(workflowReviewInboxRepository.findRequests).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-requests.controller.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-requests.controller.test.ts index f296aca14e7..33ae122e935 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-requests.controller.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-requests.controller.test.ts @@ -14,8 +14,6 @@ const serviceGatedHandlers = new Set([ 'create', 'updateVersion', 'decide', - 'listInbox', - 'getSummary', 'listActivity', 'createComment', 'getDetail', diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-version-update.controller.integration.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-version-update.controller.integration.test.ts index b1e179675ee..4e1d0e1cf1d 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-version-update.controller.integration.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-version-update.controller.integration.test.ts @@ -32,7 +32,7 @@ const workflowValidationService = mockInstance(WorkflowValidationService); const testServer = utils.setupTestServer({ endpointGroups: ['workflow-reviews', 'workflows'], enabledFeatures: ['feat:workflowReviews'], - modules: ['workflow-reviews'], + modules: ['workflow-reviews', 'inbox'], }); let owner: User; @@ -153,7 +153,7 @@ describe('POST /workflow-review-requests/:workflowReviewRequestId/update-version // Both read surfaces expose every author while keeping the original requester // canonical. Author order is the frontend's concern, so only membership is asserted. - const inbox = await ownerAgent.get('/workflow-review-requests/inbox').expect(200); + const inbox = await ownerAgent.get('/inbox').expect(200); const inboxItem = (inbox.body.data.data as WorkflowReviewInboxItem[]).find( (item) => item.id === request.id, )!; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts index fbdb4f85686..d598a28e88d 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts @@ -1,7 +1,6 @@ import type { - GetWorkflowReviewInboxSummaryResponse, - ListWorkflowReviewInboxQueryDto, - ListWorkflowReviewInboxResponse, + InboxCounts, + InboxWorkflowReviewItem, WorkflowReviewInboxItem, WorkflowReviewRequestDetail, WorkflowReviewRequestWorkflowDetail, @@ -10,7 +9,6 @@ import type { import { WorkflowReviewInboxRepository, WorkflowReviewRequestWorkflowRepository, - type InboxCursor, type User, type WorkflowHistory, type WorkflowReviewRequest, @@ -19,7 +17,8 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@n8n/errors'; +import type { InboxSourceQuery } from '../inbox/inbox-source.registry'; + import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowReviewAuthorizationService } from './workflow-review-authorization.service'; @@ -46,50 +45,40 @@ export class WorkflowReviewInboxService { private readonly participantResolver: WorkflowReviewParticipantResolver, ) {} - async listForInbox( - user: User, - query: ListWorkflowReviewInboxQueryDto, - ): Promise { - await this.featureGate.assertAvailable(); - - const visibility = await this.authorizationService.resolveInboxVisibility(user); - const { limit } = query; - const rows = await this.workflowReviewInboxRepository.findRequests({ - visibility, - state: query.state ?? 'open', - category: - query.category === undefined ? undefined : { userId: user.id, category: query.category }, - limit: limit + 1, - cursor: query.cursor ? this.decodeInboxCursor(query.cursor) : undefined, - }); - - const hasMore = rows.length > limit; - const data = rows.slice(0, limit); - const lastRow = data.at(-1); - const nextCursor = hasMore && lastRow ? this.encodeInboxCursor(lastRow) : null; - const [linkedWorkflowByRequestId, participants] = await Promise.all([ - this.workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds( - data.map((row) => row.id), - ), - this.participantResolver.resolve(data), - ]); - - return { - data: data.map((row) => - this.toInboxItem( - row, - linkedWorkflowByRequestId.get(row.id) ?? null, - participants.for(row.id), - ), - ), - nextCursor, - hasMore, - }; + async isInboxAvailable() { + return await this.featureGate.isAvailable(); } - async getInboxSummaryForUser(user: User): Promise { + async listForInbox(user: User, query: InboxSourceQuery): Promise { await this.featureGate.assertAvailable(); + const visibility = await this.authorizationService.resolveInboxVisibility(user); + const rows = await this.workflowReviewInboxRepository.findRequests({ + visibility, + state: query.state, + category: + query.category === undefined ? undefined : { userId: user.id, category: query.category }, + limit: query.limit, + boundary: query.boundary, + }); + const [linkedWorkflowByRequestId, participants] = await Promise.all([ + this.workflowReviewRequestWorkflowRepository.findLinkedWorkflowsByRequestIds( + rows.map((row) => row.id), + ), + this.participantResolver.resolve(rows), + ]); + return rows.map((row) => ({ + type: 'workflow_review', + ...this.toInboxItem( + row, + linkedWorkflowByRequestId.get(row.id) ?? null, + participants.for(row.id), + ), + })); + } + + async getInboxSummaryForUser(user: User): Promise { + await this.featureGate.assertAvailable(); const visibility = await this.authorizationService.resolveInboxVisibility(user); return await this.workflowReviewInboxRepository.countRequestsByState(visibility); } @@ -193,31 +182,6 @@ export class WorkflowReviewInboxService { }; } - /** - * Encode the keyset boundary (createdAt + id) into an opaque cursor so the - * next page is resolved without re-reading the anchor row — a review deleted - * between requests no longer truncates the rest of the inbox. - */ - private encodeInboxCursor(row: WorkflowReviewRequest): string { - return Buffer.from(`${row.createdAt.toISOString()}|${row.id}`, 'utf8').toString('base64url'); - } - - private decodeInboxCursor(cursor: string): InboxCursor { - const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); - const separatorIndex = decoded.indexOf('|'); - if (separatorIndex === -1) { - throw new BadRequestError('Invalid pagination cursor'); - } - - const createdAt = new Date(decoded.slice(0, separatorIndex)); - const id = decoded.slice(separatorIndex + 1); - if (id.length === 0 || Number.isNaN(createdAt.getTime())) { - throw new BadRequestError('Invalid pagination cursor'); - } - - return { createdAt, id }; - } - /** The review fields shared by the inbox card and the detail response. */ private toReviewSummary( entity: WorkflowReviewRequest, diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-requests.controller.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-requests.controller.ts index 924f237bb67..31a86e30971 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-requests.controller.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-requests.controller.ts @@ -6,10 +6,7 @@ import { ListWorkflowReviewActivityQueryDto, ListWorkflowReviewRequestsQueryDto, UpdateWorkflowReviewRequestVersionDto, - type GetWorkflowReviewInboxSummaryResponse, type ListWorkflowReviewActivityResponse, - type ListWorkflowReviewInboxResponse, - ListWorkflowReviewInboxQueryDto, type WorkflowReviewActivityEntry, type WorkflowReviewRequestDetail, } from '@n8n/api-types'; @@ -96,26 +93,6 @@ export class WorkflowReviewRequestsController { ); } - /** Cross-project inbox. `GET /` remains the workflow-specific list used by LIGO-838. */ - @Get('/inbox') - @Licensed('feat:workflowReviews') - async listInbox( - req: AuthenticatedRequest, - _res: Response, - @Query query: ListWorkflowReviewInboxQueryDto, - ): Promise { - return await this.workflowReviewInboxService.listForInbox(req.user, query); - } - - @Get('/summary') - @Licensed('feat:workflowReviews') - async getSummary( - req: AuthenticatedRequest, - _res: Response, - ): Promise { - return await this.workflowReviewInboxService.getInboxSummaryForUser(req.user); - } - @Get('/:workflowReviewRequestId/activity') @Licensed('feat:workflowReviews') async listActivity( diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-reviews.module.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-reviews.module.ts index 4dbe8dfe6dc..b74eec3e1e4 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-reviews.module.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-reviews.module.ts @@ -9,6 +9,15 @@ import { WorkflowMutationHooksProxy, WorkflowPublishGuardProxy } from '@n8n/back export class WorkflowReviewsModule implements ModuleInterface { async init() { await import('./workflow-review-requests.controller.js'); + const { InboxSourceRegistry } = await import('../inbox/inbox-source.registry.js'); + const { WorkflowReviewInboxService } = await import('./workflow-review-inbox.service.js'); + const inbox = Container.get(WorkflowReviewInboxService); + Container.get(InboxSourceRegistry).register({ + type: 'workflow_review', + isEnabled: async () => await inbox.isInboxAvailable(), + list: async (user, query) => await inbox.listForInbox(user, query), + count: async (user) => await inbox.getInboxSummaryForUser(user), + }); const { WorkflowReviewPublishGuard } = await import( './workflow-review-publish-guard.service.js' ); diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index ee87bc5c1cd..40fbb156634 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -15,6 +15,7 @@ import type { CredentialsOverwrites } from '@/credentials-overwrites'; import { License } from '@/license'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { MfaService } from '@/mfa/mfa.service'; +import type { InboxService } from '@/modules/inbox/inbox.service'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; import type { PushConfig } from '@/push/push.config'; import type { AiUsageService } from '@/services/ai-usage.service'; @@ -206,6 +207,7 @@ describe('FrontendService', () => { }); const workflowReviewPolicyService = mock(); + const inboxService = mock(); const createMockService = () => { Container.set( @@ -239,6 +241,7 @@ describe('FrontendService', () => { aiUsageService, workflowRepository, workflowReviewPolicyService, + inboxService, ), license, }; @@ -247,6 +250,7 @@ describe('FrontendService', () => { beforeEach(() => { originalEnv = { ...process.env }; vi.clearAllMocks(); + moduleRegistry.isActive.mockReturnValue(false); globalConfig.diagnostics.enabled = false; globalConfig.endpoints.frontendHealthCheckTimeoutMs = 5000; globalConfig.aiAssistant.baseUrl = ''; @@ -280,6 +284,44 @@ describe('FrontendService', () => { }); describe('getSettings', () => { + it('refreshes Inbox availability from registered sources on each request', async () => { + moduleRegistry.isActive.mockReturnValue(true); + inboxService.getSettings + .mockResolvedValueOnce({ + enabled: true, + availableTypes: ['workflow_review'], + failedTypes: [], + }) + .mockResolvedValueOnce({ + enabled: true, + availableTypes: [], + failedTypes: ['workflow_review'], + }); + const { service } = createMockService(); + + expect((await service.getSettings()).inbox).toEqual({ + enabled: true, + availableTypes: ['workflow_review'], + failedTypes: [], + }); + expect((await service.getSettings()).inbox).toEqual({ + enabled: true, + availableTypes: [], + failedTypes: ['workflow_review'], + }); + expect(inboxService.getSettings).toHaveBeenCalledTimes(2); + }); + + it('does not query Inbox sources when its module is inactive', async () => { + const { service } = createMockService(); + expect((await service.getSettings()).inbox).toEqual({ + enabled: false, + availableTypes: [], + failedTypes: [], + }); + expect(inboxService.getSettings).not.toHaveBeenCalled(); + }); + it('should return frontend settings', async () => { const { service } = createMockService(); const settings = await service.getSettings(); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 9b62b32780d..d297b689441 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -26,6 +26,7 @@ import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { MfaService } from '@/mfa/mfa.service'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; +import { InboxService } from '@/modules/inbox/inbox.service'; import { isApiKeyAuthEnabled } from '@/public-api'; import { PushConfig } from '@/push/push.config'; import { OwnershipService } from '@/services/ownership.service'; @@ -141,6 +142,7 @@ export class FrontendService { private readonly aiUsageService: AiUsageService, private readonly workflowRepository: WorkflowRepository, private readonly workflowReviewPolicyService: WorkflowReviewPolicyService, + private readonly inboxService: InboxService, ) { loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); credentialsOverwrites.registerReloadHandler(async () => await this.generateTypes()); @@ -666,6 +668,9 @@ export class FrontendService { } else { delete this.settings.workflowReviews; } + this.settings.inbox = this.moduleRegistry.isActive('inbox') + ? await this.inboxService.getSettings() + : { enabled: false, availableTypes: [], failedTypes: [] }; this.settings.executionMode = this.globalConfig.executions.mode; diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index ec325c0fe60..dad36fbb4fb 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -67,6 +67,7 @@ type EndpointGroup = | 'type-availability-policies'; type ModuleName = + | 'inbox' | 'insights' | 'external-secrets' | 'community-packages' diff --git a/packages/frontend/editor-ui/src/app/composables/useIntersectionObserver.test.ts b/packages/frontend/@n8n/composables/src/useIntersectionObserver.test.ts similarity index 89% rename from packages/frontend/editor-ui/src/app/composables/useIntersectionObserver.test.ts rename to packages/frontend/@n8n/composables/src/useIntersectionObserver.test.ts index b60f207bdd1..c81f36e5c59 100644 --- a/packages/frontend/editor-ui/src/app/composables/useIntersectionObserver.test.ts +++ b/packages/frontend/@n8n/composables/src/useIntersectionObserver.test.ts @@ -93,9 +93,9 @@ describe('useIntersectionObserver()', () => { observe(element); // Simulate intersection - const callback = MockIntersectionObserver.getInstance().__callback; - if (callback) { - callback([createMockEntry(element, true)], {} as IntersectionObserver); + const onIntersect = MockIntersectionObserver.getInstance().__callback; + if (onIntersect) { + onIntersect([createMockEntry(element, true)], {} as IntersectionObserver); } expect(mockCallback).toHaveBeenCalledTimes(1); @@ -112,9 +112,9 @@ describe('useIntersectionObserver()', () => { observe(element); // Simulate no intersection - const callback = MockIntersectionObserver.getInstance().__callback; - if (callback) { - callback([createMockEntry(element, false)], {} as IntersectionObserver); + const onIntersect = MockIntersectionObserver.getInstance().__callback; + if (onIntersect) { + onIntersect([createMockEntry(element, false)], {} as IntersectionObserver); } expect(mockCallback).not.toHaveBeenCalled(); @@ -131,9 +131,9 @@ describe('useIntersectionObserver()', () => { observe(element); // Simulate intersection - const callback = MockIntersectionObserver.getInstance().__callback; - if (callback) { - callback([createMockEntry(element, true)], {} as IntersectionObserver); + const onIntersect = MockIntersectionObserver.getInstance().__callback; + if (onIntersect) { + onIntersect([createMockEntry(element, true)], {} as IntersectionObserver); } expect(MockIntersectionObserver.getInstance().disconnect).toHaveBeenCalledTimes(1); @@ -151,9 +151,9 @@ describe('useIntersectionObserver()', () => { observe(element); // Simulate intersection - const callback = MockIntersectionObserver.getInstance().__callback; - if (callback) { - callback([createMockEntry(element, true)], {} as IntersectionObserver); + const onIntersect = MockIntersectionObserver.getInstance().__callback; + if (onIntersect) { + onIntersect([createMockEntry(element, true)], {} as IntersectionObserver); } expect(MockIntersectionObserver.getInstance().disconnect).not.toHaveBeenCalled(); diff --git a/packages/frontend/@n8n/composables/src/useIntersectionObserver.ts b/packages/frontend/@n8n/composables/src/useIntersectionObserver.ts new file mode 100644 index 00000000000..5c478887bf9 --- /dev/null +++ b/packages/frontend/@n8n/composables/src/useIntersectionObserver.ts @@ -0,0 +1,92 @@ +import { ref, onBeforeUnmount, type Ref } from 'vue'; + +export interface UseIntersectionObserverOptions { + /** + * The root element for intersection detection (scrollable container) + */ + root: Ref; + + /** + * Threshold for intersection (0.0 to 1.0) + * @default 0.01 + */ + threshold?: number; + + /** + * Callback to execute when element intersects + */ + onIntersect: () => void; + + /** + * Whether to disconnect observer after first intersection + * @default true + */ + once?: boolean; +} + +/** + * Composable for observing element intersections with automatic cleanup + * + * @example + * const { observe } = useIntersectionObserver({ + * root: containerRef, + * onIntersect: () => loadMore(), + * }); + * + * // Observe last item in list + * observe(lastItemElement); + */ +export function useIntersectionObserver(options: UseIntersectionObserverOptions) { + const observer = ref(null); + + /** + * Stop observing and clean up the observer + */ + const disconnect = () => { + if (observer.value) { + observer.value.disconnect(); + observer.value = null; + } + }; + + /** + * Start observing an element for intersection + * Automatically cleans up any existing observer before creating a new one + */ + const observe = (element: Element | null | undefined) => { + if (!element) return; + + // Clean up existing observer if any + disconnect(); + + observer.value = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + // Disconnect first. The callback can call observe() and create a new observer. + if (options.once !== false) { + disconnect(); + } + + options.onIntersect(); + } + }, + { + root: options.root.value, + threshold: options.threshold ?? 0.01, + }, + ); + + observer.value.observe(element); + }; + + // Cleanup observer when component unmounts + onBeforeUnmount(() => { + disconnect(); + }); + + return { + observer, + observe, + disconnect, + }; +} diff --git a/packages/frontend/@n8n/composables/src/useWorkflowVersionLabel.ts b/packages/frontend/@n8n/composables/src/useWorkflowVersionLabel.ts new file mode 100644 index 00000000000..8fa0db5fc27 --- /dev/null +++ b/packages/frontend/@n8n/composables/src/useWorkflowVersionLabel.ts @@ -0,0 +1,23 @@ +import { useI18n } from '@n8n/i18n'; + +export const generateVersionLabelFromId = (versionId: string) => { + return `Version ${versionId.substring(0, 8)}`; +}; + +export const getVersionLabel = ({ + workflowHistory, + currentVersionId, +}: { + workflowHistory: { versionId: string; name?: string | null }; + currentVersionId?: string; +}) => { + const i18n = useI18n(); + if (workflowHistory.name) { + return workflowHistory.name; + } + + const isCurrentVersion = workflowHistory.versionId === currentVersionId; + return isCurrentVersion + ? i18n.baseText('workflowHistory.item.currentChanges') + : generateVersionLabelFromId(workflowHistory.versionId); +}; diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts index 58403a1d370..e8e42b07768 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts @@ -1,3 +1,5 @@ +import type { WorkflowReviewVersionSnapshot } from '@n8n/api-types'; + /** * UI a module renders but does not own. * @@ -9,8 +11,9 @@ * decide between local and remote search, and the search itself all live in * `features/collaboration/projects`, which sits above the module layer. A module * that needs to filter by project renders this instead of importing any of it. + * `workflow-diff` renders plain workflow snapshots with the editor canvas. */ -export type ModuleComponentSlot = 'project-filter'; +export type ModuleComponentSlot = 'project-filter' | 'workflow-diff'; /** * The `v-model` value of the `project-filter` slot. `null` means "all projects". @@ -20,3 +23,14 @@ export type ModuleComponentSlot = 'project-filter'; * originates in the host. */ export type SlotProjectSelection = { id: string } | null; + +/** Plain snapshots keep the editor canvas types inside the shell. */ +export type SlotWorkflowDiffProps = { + workflowId: string; + workflowName: string; + sourceSnapshot?: WorkflowReviewVersionSnapshot; + targetSnapshot?: WorkflowReviewVersionSnapshot; + sourceLabel?: string; + targetLabel?: string; + showFullscreenButton?: boolean; +}; diff --git a/packages/frontend/@n8n/frontend-vite-config/index.ts b/packages/frontend/@n8n/frontend-vite-config/index.ts index d6af0f9bc81..cf63cef54ac 100644 --- a/packages/frontend/@n8n/frontend-vite-config/index.ts +++ b/packages/frontend/@n8n/frontend-vite-config/index.ts @@ -44,6 +44,7 @@ export const sourcePackages = [ * The module tsconfig base holds that boundary. */ export const modulePackages: Array<{ name: string; dir: string; entry?: boolean }> = [ + { name: '@n8n/frontend-module-inbox', dir: 'modules/inbox/frontend' }, { name: '@n8n/frontend-module-mcp', dir: 'modules/mcp/frontend' }, { name: '@n8n/frontend-module-type-availability-policies', diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index aab31aa8599..c741272ad0c 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1,4 +1,19 @@ { + "inbox.title": "Reviews", + "inbox.tabs.open": "Open", + "inbox.tabs.closed": "Closed", + "inbox.partial": "Some items couldn’t load. Retry to refresh the list.", + "inbox.loadError": "Couldn't load reviews", + "inbox.loadMore": "Load more", + "inbox.empty.open": "No open reviews", + "inbox.empty.closed": "No closed reviews", + "inbox.empty.body.open": "Reviews you authored or participate in appear here.", + "inbox.empty.body.closed": "Reviews that were approved, rejected, or cancelled appear here.", + "inbox.items.open": "Open reviews", + "inbox.items.closed": "Closed reviews", + "inbox.noSelection.title.open": "{count} open review | {count} open reviews", + "inbox.noSelection.title.closed": "{count} closed review | {count} closed reviews", + "inbox.noSelection.body": "Select a review from the list to see its activity and changes.", "_reusableBaseText": { "cancel": "Cancel", "codeNodeEditor": { @@ -6118,17 +6133,9 @@ "settings.security.workflowReviews.error": "Failed to update workflow reviews settings", "settings.security.workflowReviews.confirmDisable.headline": "Disable workflow reviews", "settings.security.workflowReviews.confirmDisable.message": "Workflow reviews will no longer be available on this instance. Existing project-level review settings will be ignored.", - "workflowReviews.menu.title": "Reviews", - "workflowReviews.openReviews": "Open reviews", - "workflowReviews.closedReviews": "Closed reviews", - "workflowReviews.page.title": "Reviews", - "workflowReviews.sidebar.tabs.open": "Open", - "workflowReviews.sidebar.tabs.closed": "Closed", "workflowReviews.sidebar.section.waiting.title": "Waiting for your review", "workflowReviews.sidebar.section.waiting.titleAdmin": "Waiting for review", "workflowReviews.sidebar.section.authored.title": "Authored by you", - "workflowReviews.sidebar.loadMore": "Load more", - "workflowReviews.sidebar.error": "Couldn't load reviews", "workflowReviews.status.open": "Open", "workflowReviews.status.closed": "Closed", "workflowReviews.decision.pending": "Waiting for review", @@ -6198,13 +6205,6 @@ "workflowReviews.detail.decision.approveAndPublish": "Approve and publish", "workflowReviews.detail.decision.requestChanges": "Request changes", "workflowReviews.detail.decision.ineligible.author": "You contributed a version to this review.", - "workflowReviews.emptyState.title.open": "No open reviews", - "workflowReviews.emptyState.title.closed": "No closed reviews", - "workflowReviews.emptyState.body.open": "Reviews you authored or participate in appear here.", - "workflowReviews.emptyState.body.closed": "Reviews that were approved, rejected, or cancelled appear here.", - "workflowReviews.noSelection.title.open": "{count} open review | {count} open reviews", - "workflowReviews.noSelection.title.closed": "{count} closed review | {count} closed reviews", - "workflowReviews.noSelection.body": "Select a review from the list to see its activity and changes.", "workflowReviews.error.load": "Could not load workflow reviews", "workflowReviews.decision.error.title": "Could not submit review decision", "workflowReviews.decision.approved.published.title": "Review approved", diff --git a/packages/frontend/@n8n/stores/src/settings.store.test.ts b/packages/frontend/@n8n/stores/src/settings.store.test.ts index 002fb6f7a1e..7c384d601c1 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.test.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.test.ts @@ -132,6 +132,56 @@ describe('settings.store', () => { setActivePinia(createPinia()); }); + describe('setWorkflowReviewsPolicy', () => { + beforeEach(() => { + const store = useSettingsStore(); + store.settings.activeModules = ['inbox', 'workflow-reviews']; + store.settings.enterprise = { ...mockSettings.enterprise, workflowReviews: true }; + store.settings.inbox = { enabled: false, availableTypes: [], failedTypes: [] }; + }); + + it('disables Inbox when its last source is disabled', () => { + const store = useSettingsStore(); + store.setWorkflowReviewsPolicy({ enabled: true }); + expect(store.settings.inbox?.enabled).toBe(true); + + store.setWorkflowReviewsPolicy({ enabled: false }); + + expect(store.settings.inbox).toEqual({ + enabled: false, + availableTypes: [], + failedTypes: [], + }); + }); + + it.each([ + { licensed: false, activeModules: ['inbox', 'workflow-reviews'] }, + { licensed: true, activeModules: ['inbox'] }, + { licensed: true, activeModules: ['workflow-reviews'] }, + ])('keeps license and module gates: %j', ({ licensed, activeModules }) => { + const store = useSettingsStore(); + store.settings.enterprise.workflowReviews = licensed; + store.settings.activeModules = activeModules; + + store.setWorkflowReviewsPolicy({ enabled: true }); + + expect(store.settings.inbox).toEqual({ + enabled: false, + availableTypes: [], + failedTypes: [], + }); + }); + + it('does not invent Inbox settings when they are absent', () => { + const store = useSettingsStore(); + delete store.settings.inbox; + + store.setWorkflowReviewsPolicy({ enabled: true }); + + expect(store.settings.inbox).toBeUndefined(); + }); + }); + describe('isAutosaveEnabled', () => { it('should return true when workflowsAutosaveDisabled is false', async () => { getSettings.mockResolvedValueOnce({ diff --git a/packages/frontend/@n8n/stores/src/settings.store.ts b/packages/frontend/@n8n/stores/src/settings.store.ts index aaabee069b7..0e4550c101a 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.ts @@ -316,6 +316,21 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const setWorkflowReviewsPolicy = (policy: WorkflowReviewsPolicy) => { settings.value.workflowReviews = policy; + + const inbox = settings.value.inbox; + if (!inbox || !isModuleActive('inbox')) return; + + // Update Inbox access from the saved policy. + inbox.availableTypes = inbox.availableTypes.filter((type) => type !== 'workflow_review'); + inbox.failedTypes = inbox.failedTypes.filter((type) => type !== 'workflow_review'); + if ( + policy.enabled && + isEnterpriseFeatureEnabled.value.workflowReviews && + isModuleActive('workflow-reviews') + ) { + inbox.availableTypes.push('workflow_review'); + } + inbox.enabled = inbox.availableTypes.length > 0 || inbox.failedTypes.length > 0; }; const setSaveDataErrorExecution = (newValue: WorkflowSettings.SaveDataExecution) => { diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 344ce15493b..5d14bbac77f 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -54,6 +54,7 @@ "@n8n/eslint-config": "workspace:*", "@n8n/expression-runtime": "workspace:*", "@n8n/frontend-constants": "workspace:*", + "@n8n/frontend-module-inbox": "workspace:*", "@n8n/frontend-module-insights": "workspace:*", "@n8n/frontend-module-instance-registry": "workspace:*", "@n8n/frontend-module-mcp": "workspace:*", diff --git a/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts b/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts index 436b0d090f6..ca3ec367e0f 100644 --- a/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts +++ b/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts @@ -10,6 +10,13 @@ import { defineAsyncComponent } from 'vue'; * design-system tree), which is exactly what a module renders a slot to avoid. */ export const registerComponentSlots = () => { + componentRegistry.register( + 'workflow-diff', + defineAsyncComponent( + async () => await import('@/features/workflows/workflowDiff/WorkflowDiffSlot.vue'), + ), + ); + componentRegistry.register( 'project-filter', defineAsyncComponent( diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts index 7d556e691f7..b6647d864e3 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts @@ -47,8 +47,8 @@ import { ResponseError } from '@n8n/rest-api-client'; // Hoisted: the vue-router mock factory runs before module-level consts initialize const { mockRouterPush, mockRouterResolve } = vi.hoisted(() => ({ mockRouterPush: vi.fn(), - mockRouterResolve: vi.fn(({ params }: { params: { reviewRequestId: string } }) => ({ - href: `/reviews/${params.reviewRequestId}`, + mockRouterResolve: vi.fn(({ params }: { params: { reviewId: string } }) => ({ + href: `/inbox/reviews/${params.reviewId}`, })), })); @@ -712,7 +712,7 @@ describe('WorkflowHeaderDraftPublishActions', () => { expect(mockShowToast).toHaveBeenCalledWith({ type: 'success', title: 'Latest changes submitted to the existing review', - message: 'Open review', + message: 'Open review', onClick: expect.any(Function), }); const toastConfig = mockShowToast.mock.calls.at(-1)?.[0]; @@ -725,7 +725,7 @@ describe('WorkflowHeaderDraftPublishActions', () => { expect(preventDefault).toHaveBeenCalledOnce(); expect(mockRouterPush).toHaveBeenCalledWith({ name: WORKFLOW_REVIEW_REQUESTS_VIEW, - params: { reviewRequestId: 'req-1' }, + params: { reviewId: 'req-1' }, }); }); @@ -819,7 +819,7 @@ describe('WorkflowHeaderDraftPublishActions', () => { expect(mockShowToast).toHaveBeenCalledWith({ type: 'success', title: 'Workflow version submitted for review', - message: 'Open review', + message: 'Open review', onClick: expect.any(Function), }); }); @@ -845,7 +845,7 @@ describe('WorkflowHeaderDraftPublishActions', () => { expect(mockShowToast).toHaveBeenCalledWith({ type: 'success', title: 'Workflow version submitted for review', - message: 'Open review', + message: 'Open review', onClick: expect.any(Function), }); }); @@ -1630,7 +1630,7 @@ describe('WorkflowHeaderDraftPublishActions', () => { expect(mockRouterPush).toHaveBeenCalledWith({ name: WORKFLOW_REVIEW_REQUESTS_VIEW, - params: { reviewRequestId: 'req-1' }, + params: { reviewId: 'req-1' }, query: undefined, }); }); diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue index 58e88e82175..3b3e20942e4 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue @@ -320,7 +320,7 @@ const flushSaveForReview = async (): Promise => { const showReviewToast = (titleKey: BaseTextKey, reviewRequestId: string) => { const reviewRoute = { name: WORKFLOW_REVIEW_REQUESTS_VIEW, - params: { reviewRequestId }, + params: { reviewId: reviewRequestId }, }; const reviewUrl = router.resolve(reviewRoute).href; @@ -373,7 +373,7 @@ const onOpenReviewFromBanner = async () => { await router.push({ name: WORKFLOW_REVIEW_REQUESTS_VIEW, - params: { reviewRequestId: review.id }, + params: { reviewId: review.id }, // The inbox opens on its "Open" tab, so a closed review would land in a list // that cannot contain its card. Keyed off the review's own state rather than // an approved decision, so any way a review gets closed still lands right. diff --git a/packages/frontend/editor-ui/src/app/composables/useIntersectionObserver.ts b/packages/frontend/editor-ui/src/app/composables/useIntersectionObserver.ts index eb08aab6451..dcb68d0f82e 100644 --- a/packages/frontend/editor-ui/src/app/composables/useIntersectionObserver.ts +++ b/packages/frontend/editor-ui/src/app/composables/useIntersectionObserver.ts @@ -1,91 +1,2 @@ -import { ref, onBeforeUnmount, type Ref } from 'vue'; - -export interface UseIntersectionObserverOptions { - /** - * The root element for intersection detection (scrollable container) - */ - root: Ref; - - /** - * Threshold for intersection (0.0 to 1.0) - * @default 0.01 - */ - threshold?: number; - - /** - * Callback to execute when element intersects - */ - onIntersect: () => void; - - /** - * Whether to disconnect observer after first intersection - * @default true - */ - once?: boolean; -} - -/** - * Composable for observing element intersections with automatic cleanup - * - * @example - * const { observe } = useIntersectionObserver({ - * root: containerRef, - * onIntersect: () => loadMore(), - * }); - * - * // Observe last item in list - * observe(lastItemElement); - */ -export function useIntersectionObserver(options: UseIntersectionObserverOptions) { - const observer = ref(null); - - /** - * Stop observing and clean up the observer - */ - const disconnect = () => { - if (observer.value) { - observer.value.disconnect(); - observer.value = null; - } - }; - - /** - * Start observing an element for intersection - * Automatically cleans up any existing observer before creating a new one - */ - const observe = (element: Element | null | undefined) => { - if (!element) return; - - // Clean up existing observer if any - disconnect(); - - observer.value = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - options.onIntersect(); - - if (options.once !== false) { - disconnect(); - } - } - }, - { - root: options.root.value, - threshold: options.threshold ?? 0.01, - }, - ); - - observer.value.observe(element); - }; - - // Cleanup observer when component unmounts - onBeforeUnmount(() => { - disconnect(); - }); - - return { - observer, - observe, - disconnect, - }; -} +export { useIntersectionObserver } from '@n8n/composables/useIntersectionObserver'; +export type { UseIntersectionObserverOptions } from '@n8n/composables/useIntersectionObserver'; diff --git a/packages/frontend/editor-ui/src/app/constants/localStorage.ts b/packages/frontend/editor-ui/src/app/constants/localStorage.ts index 5bf01f8349d..3abfbf4777a 100644 --- a/packages/frontend/editor-ui/src/app/constants/localStorage.ts +++ b/packages/frontend/editor-ui/src/app/constants/localStorage.ts @@ -51,9 +51,6 @@ export const LOCAL_STORAGE_WORKFLOW_REVIEW_PUBLISH_CHOICE_HIDDEN = (userId: stri `${userId}_N8N_WORKFLOW_REVIEW_PUBLISH_CHOICE_HIDDEN`; export const LOCAL_STORAGE_WORKFLOW_REVIEW_SUBMITTED_DIALOG_HIDDEN = (userId: string) => `${userId}_N8N_WORKFLOW_REVIEW_SUBMITTED_DIALOG_HIDDEN`; -export const LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS = (userId: string) => - `${userId}_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS`; -export const LOCAL_STORAGE_WORKFLOW_REVIEW_SIDEBAR_WIDTH = 'N8N_WORKFLOW_REVIEW_SIDEBAR_WIDTH'; export const LOCAL_STORAGE_EVALUATIONS_CANVAS_INFO_CARD_DISMISSED = 'N8N_EVALUATIONS_CANVAS_INFO_CARD_DISMISSED'; export const LOCAL_STORAGE_CANVAS_GROUP_EXPANDED = 'N8N_CANVAS_GROUP_EXPANDED'; diff --git a/packages/frontend/editor-ui/src/app/modules.manifest.ts b/packages/frontend/editor-ui/src/app/modules.manifest.ts index 8d31867abc5..f48cd6f4d22 100644 --- a/packages/frontend/editor-ui/src/app/modules.manifest.ts +++ b/packages/frontend/editor-ui/src/app/modules.manifest.ts @@ -4,7 +4,7 @@ import { MCPModule } from '@n8n/frontend-module-mcp/mcp.module'; import { ChatModule } from '@/features/ai/chatHub/module.descriptor'; import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor'; import { AgentsModule } from '@/features/agents/module.descriptor'; -import { WorkflowReviewsModule } from '@/features/workflow-reviews/module.descriptor'; +import { InboxModule } from '@n8n/frontend-module-inbox'; import { InstanceRegistryModule } from '@n8n/frontend-module-instance-registry'; import { OtelModule } from '@n8n/frontend-module-otel'; import { InsightsModule } from '@n8n/frontend-module-insights/insights.module'; @@ -36,7 +36,7 @@ export const modules: FrontendModuleDescription[] = [ InstanceAiModule, AgentsModule, OtelModule, - WorkflowReviewsModule, + InboxModule, InstanceRegistryModule, InsightsModule, PromotionsModule, diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue index 1e689716c0b..285e81d9d98 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue @@ -24,8 +24,7 @@ import { } from '@/features/ai/instanceAi/constants'; import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store'; -import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants'; -import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature'; +import { INBOX_VIEW } from '@n8n/frontend-module-inbox'; import { AGENT_N8N_CHAT_VIEW, AGENT_N8N_CHAT_RECENT_THREADS_LIMIT, @@ -212,13 +211,13 @@ function onChatItemClick(item: RecentChatItem): void { agentTelemetry.trackClickedSidebarItem({ item: 'chat', chatType: item.kind }); } -const { isWorkflowReviewsEnabled: isWorkflowReviewsNavVisible } = useWorkflowReviewsFeature(); +const isInboxVisible = computed(() => settingsStore.settings.inbox?.enabled === true); -const workflowReviews = computed(() => ({ - id: 'workflow-reviews', +const inbox = computed(() => ({ + id: 'inbox', icon: 'message-square-text', - label: locale.baseText('workflowReviews.menu.title'), - route: { to: { name: WORKFLOW_REVIEW_REQUESTS_VIEW } }, + label: locale.baseText('inbox.title'), + route: { to: { name: INBOX_VIEW } }, })); const chat = computed(() => ({ id: 'chat', @@ -290,11 +289,11 @@ onBeforeUnmount(() => { data-test-id="project-shared-menu-item" /> { route.name === VIEWS.SHARED_CREDENTIALS, ); - const isWorkflowReviewsSubPage = computed(() => route.name === WORKFLOW_REVIEW_REQUESTS_VIEW); + const isWorkflowReviewsSubPage = computed(() => isInboxRoute(route)); return reactive({ isOverviewSubPage, diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.test.ts index 748c8b0e568..3bcf7689dd8 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.test.ts @@ -1,5 +1,5 @@ import { setActivePinia, createPinia } from 'pinia'; -import { reactive } from 'vue'; +import { nextTick, reactive } from 'vue'; import { vi } from 'vitest'; import { useProjectsStore } from './projects.store'; import * as projectsApi from './projects.api'; @@ -338,4 +338,16 @@ describe('useProjectsStore.setProjectNavActiveIdByWorkflowHomeProject', () => { expect(store.currentProject).toBeNull(); expect(store.projectNavActiveId).toBe('shared'); }); + + it('clears the workflow project context when opening Reviews', async () => { + const store = makeStore(); + store.currentProject = makeProject('team-1'); + await store.setProjectNavActiveIdByWorkflowHomeProject(store.currentProject); + + route.path = '/inbox'; + await nextTick(); + + expect(store.projectNavActiveId).toBe('inbox'); + expect(store.currentProject).toBeNull(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.ts index e8c748925f4..14894099d8a 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.store.ts @@ -357,8 +357,8 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => { setCurrentProject(null); } - if (newRoute?.path?.includes('/reviews')) { - projectNavActiveId.value = 'workflow-reviews'; + if (newRoute?.path?.includes('/inbox')) { + projectNavActiveId.value = 'inbox'; setCurrentProject(null); } diff --git a/packages/frontend/editor-ui/src/features/settings/security/SecuritySettings.test.ts b/packages/frontend/editor-ui/src/features/settings/security/SecuritySettings.test.ts index 56008ca6649..b689effbf28 100644 --- a/packages/frontend/editor-ui/src/features/settings/security/SecuritySettings.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/security/SecuritySettings.test.ts @@ -939,6 +939,25 @@ describe('SecuritySettings', () => { }); }); + it('updates Inbox access after saving the policy without reloading settings', async () => { + settingsStore.settings.activeModules = ['inbox', 'workflow-reviews']; + settingsStore.settings.inbox = { enabled: false, availableTypes: [], failedTypes: [] }; + updateSecuritySettings.mockResolvedValue({ workflowReviews: { enabled: true } }); + const { findByTestId } = renderView(); + const toggle = await findByTestId('security-workflow-reviews-toggle'); + await userEvent.click(toggle); + await waitFor(() => expect(showToast).toHaveBeenCalled()); + expect(settingsStore.settings.workflowReviews).toEqual({ enabled: true }); + expect(settingsStore.settings.inbox).toEqual({ + enabled: true, + availableTypes: ['workflow_review'], + failedTypes: [], + }); + expect(toggle).not.toHaveClass('is-disabled'); + expect(showError).not.toHaveBeenCalled(); + expect(settingsStore.getSettings).not.toHaveBeenCalled(); + }); + it('should disable workflow reviews toggle when managed by env', async () => { getSecuritySettings.mockResolvedValue({ ...defaultSettings, diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.test.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.test.ts deleted file mode 100644 index a67c017a761..00000000000 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import type { WorkflowReviewInboxItem } from '@n8n/api-types'; -import { createTestingPinia } from '@pinia/testing'; -import { fireEvent } from '@testing-library/vue'; -import { nextTick } from 'vue'; -import { createComponentRenderer } from '@/__tests__/render'; -import { mockedStore } from '@/__tests__/utils'; -import { LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS } from '@/app/constants/localStorage'; -import { useUsersStore } from '@n8n/stores/users.store'; -import WorkflowReviewRequestsSidebar from './WorkflowReviewRequestsSidebar.vue'; -import type { ReviewInboxSidebarSection } from './WorkflowReviewRequestsSidebar.vue'; - -const observe = vi.fn(); -vi.mock('@/app/composables/useIntersectionObserver', () => ({ - useIntersectionObserver: () => ({ - observe: (...args: unknown[]) => observe(...args), - disconnect: vi.fn(), - observer: { value: null }, - }), -})); - -const renderComponent = createComponentRenderer(WorkflowReviewRequestsSidebar); - -const CURRENT_USER_ID = 'current-user'; - -const reviewers = [ - { - id: 'user-reviewer-1', - email: 'reviewer1@example.com', - firstName: 'Rey', - lastName: 'One', - }, - { - id: 'user-reviewer-2', - email: 'reviewer2@example.com', - firstName: 'Reba', - lastName: 'Two', - }, - { - id: 'user-reviewer-3', - email: 'reviewer3@example.com', - firstName: 'Ron', - lastName: 'Three', - }, -]; - -function makeItem(overrides: Partial = {}): WorkflowReviewInboxItem { - return { - id: 'req-1', - projectId: 'proj-1', - title: 'Needs review', - workflowName: 'My workflow', - requester: null, - authors: [], - reviewers: reviewers.slice(0, 1), - decision: 'pending', - state: 'open', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - ...overrides, - workflowVersionId: overrides.workflowVersionId ?? null, - }; -} - -function makeSection( - key: ReviewInboxSidebarSection['key'], - overrides: Partial = {}, -): ReviewInboxSidebarSection { - return { - key, - items: [], - loadingMore: false, - hasMore: false, - error: null, - ...overrides, - }; -} - -function openProps(overrides: Partial[] = [{}, {}]) { - return { - sections: [makeSection('waiting', overrides[0]), makeSection('authored', overrides[1])], - loading: false, - initialLoadFailed: false, - activeTab: 'open' as const, - openCount: 1, - closedCount: 0, - selectedId: null, - }; -} - -function closedProps(overrides: Partial = {}) { - return { - sections: [makeSection('closed', overrides)], - loading: false, - initialLoadFailed: false, - activeTab: 'closed' as const, - openCount: 0, - closedCount: 1, - selectedId: null, - }; -} - -describe('WorkflowReviewRequestsSidebar', () => { - beforeEach(() => { - localStorage.clear(); - observe.mockClear(); - createTestingPinia(); - const usersStore = mockedStore(useUsersStore); - usersStore.currentUser = { - id: CURRENT_USER_ID, - email: 'me@example.com', - firstName: 'Me', - lastName: 'User', - } as never; - usersStore.currentUserId = CURRENT_USER_ID; - }); - - describe('cards', () => { - it.each([ - { state: 'open' as const, decision: 'pending' as const, label: 'Open | Waiting for review' }, - { - state: 'open' as const, - decision: 'changes_requested' as const, - label: 'Open | Changes requested', - }, - { state: 'closed' as const, decision: 'approved' as const, label: 'Closed | Approved' }, - { state: 'closed' as const, decision: 'pending' as const, label: 'Closed | No decision' }, - ])('maps $state + $decision to the "$label" status indicator', ({ state, decision, label }) => { - const props = - state === 'closed' - ? closedProps({ items: [makeItem({ state, decision })] }) - : openProps([{ items: [makeItem({ state, decision })] }, {}]); - const { getByTestId } = renderComponent({ props }); - - expect(getByTestId('workflow-review-request-status-dot')).toHaveAttribute( - 'aria-label', - label, - ); - }); - }); - - describe('sections', () => { - it('renders both open headers in order, waiting before authored', () => { - const { getAllByTestId } = renderComponent({ - props: openProps([{ items: [makeItem()] }, { items: [makeItem({ id: 'req-2' })] }]), - }); - - const headers = getAllByTestId('workflow-review-section-header'); - expect(headers.map((header) => header.dataset.section)).toEqual(['waiting', 'authored']); - expect(headers[0]).toHaveTextContent('Waiting for your review'); - expect(headers[1]).toHaveTextContent('Authored by you'); - }); - - it('drops a settled empty section, keeping the populated sibling', () => { - const { getAllByTestId } = renderComponent({ - props: openProps([{ items: [makeItem()] }, {}]), - }); - - const headers = getAllByTestId('workflow-review-section-header'); - expect(headers.map((header) => header.dataset.section)).toEqual(['waiting']); - expect(getAllByTestId('workflow-review-request-row')).toHaveLength(1); - }); - - it('drops every section when the whole tab is empty', () => { - const { queryAllByTestId } = renderComponent({ props: openProps() }); - - expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0); - expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0); - }); - - it('renders no header on the closed tab', () => { - const { queryAllByTestId, getAllByTestId } = renderComponent({ - props: closedProps({ items: [makeItem({ state: 'closed' })] }), - }); - - expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0); - expect(getAllByTestId('workflow-review-request-row')).toHaveLength(1); - }); - - it('gives each section its own labelled listbox holding only options', () => { - const { container } = renderComponent({ - props: openProps([{ items: [makeItem()] }, { items: [makeItem({ id: 'req-2' })] }]), - }); - - const listboxes = container.querySelectorAll('[role="listbox"]'); - expect(listboxes).toHaveLength(2); - expect(listboxes[0].getAttribute('aria-labelledby')).toBe( - 'workflow-review-section-header-waiting', - ); - expect(listboxes[0].querySelectorAll('[role="option"]')).toHaveLength(1); - expect(listboxes[0].querySelector('button')).toBeNull(); - }); - - it('keeps the section headers and controls outside any listbox', () => { - const { getAllByTestId } = renderComponent({ - props: openProps([{ error: new Error('nope'), hasMore: true }, {}]), - }); - - for (const testId of [ - 'workflow-review-section-header', - 'workflow-review-section-retry', - 'workflow-review-section-load-more', - ]) { - for (const element of getAllByTestId(testId)) { - expect(element.closest('[role="listbox"]')).toBeNull(); - } - } - }); - - // The member wording is already covered by the header-order test above. - it('drops the possessive waiting labels for an admin', () => { - mockedStore(useUsersStore).isAdminOrOwner = true; - - const { getAllByTestId } = renderComponent({ - props: openProps([{ items: [makeItem()] }, {}]), - }); - - expect(getAllByTestId('workflow-review-section-header')[0]).toHaveTextContent( - 'Waiting for review', - ); - }); - - it('shows one skeleton and no headers while the active tab loads', () => { - const { getAllByTestId, queryAllByTestId } = renderComponent({ - props: { ...openProps(), loading: true }, - }); - - expect(getAllByTestId('workflow-review-list-skeleton')).toHaveLength(1); - expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0); - }); - - it('renders the tabs without counts while the summary is unavailable', () => { - const { getByTestId } = renderComponent({ - props: { ...openProps(), openCount: null, closedCount: null }, - }); - - const tabs = getByTestId('workflow-reviews-tabs'); - expect(tabs).toHaveTextContent('Open'); - expect(tabs.querySelectorAll('.n8n-tag')).toHaveLength(0); - }); - - it('keeps the whole-list skeleton while either open section is loading', () => { - const { getAllByTestId, queryAllByTestId } = renderComponent({ - props: { - ...openProps([{}, { items: [makeItem({ id: 'authored-1' })] }]), - loading: true, - }, - }); - - expect(getAllByTestId('workflow-review-list-skeleton')).toHaveLength(1); - expect(queryAllByTestId('workflow-review-section-header')).toHaveLength(0); - }); - }); - - describe('collapsing', () => { - it('hides rows, flips aria-expanded, and persists the choice', async () => { - const { getAllByTestId, queryAllByTestId } = renderComponent({ - props: openProps([{ items: [makeItem()] }, {}]), - }); - - const [waitingHeader] = getAllByTestId('workflow-review-section-header'); - expect(waitingHeader).toHaveAttribute('aria-expanded', 'true'); - expect(waitingHeader).toHaveAttribute( - 'aria-controls', - 'workflow-review-section-group-waiting', - ); - - await fireEvent.click(waitingHeader); - - expect(waitingHeader).toHaveAttribute('aria-expanded', 'false'); - expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0); - await nextTick(); - expect( - localStorage.getItem( - LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS(CURRENT_USER_ID), - ), - ).toContain('"waiting":true'); - }); - - it('starts collapsed from persisted state and re-expands without refetching', async () => { - localStorage.setItem( - LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS(CURRENT_USER_ID), - JSON.stringify({ waiting: true, authored: false }), - ); - - const { getAllByTestId, queryAllByTestId, emitted } = renderComponent({ - props: openProps([{ items: [makeItem()] }, {}]), - }); - - expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0); - - await fireEvent.click(getAllByTestId('workflow-review-section-header')[0]); - - expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(1); - expect(emitted().loadMore).toBeUndefined(); - }); - }); - - describe('load more', () => { - it('renders the button only for the section that has more, and emits its key', async () => { - const { getAllByTestId, emitted } = renderComponent({ - props: openProps([ - { items: [makeItem()] }, - { items: [makeItem({ id: 'req-2' })], hasMore: true }, - ]), - }); - - const buttons = getAllByTestId('workflow-review-section-load-more'); - expect(buttons).toHaveLength(1); - expect(buttons[0].dataset.section).toBe('authored'); - - await fireEvent.click(buttons[0]); - - expect(emitted().loadMore).toEqual([['authored']]); - }); - - it('keeps the sentinel instead of a button on the closed tab', async () => { - const { queryAllByTestId } = renderComponent({ - props: closedProps({ items: [makeItem({ state: 'closed' })], hasMore: true }), - }); - await nextTick(); - - expect(queryAllByTestId('workflow-review-section-load-more')).toHaveLength(0); - expect(observe).toHaveBeenCalled(); - }); - - it('does not observe a sentinel on the open tab', async () => { - renderComponent({ - props: openProps([{ items: [makeItem()], hasMore: true }, {}]), - }); - await nextTick(); - - expect(observe).not.toHaveBeenCalled(); - }); - }); - - describe('errors', () => { - it('shows one retry when an initial failure leaves no usable rows', async () => { - const { getByTestId, queryAllByTestId, emitted } = renderComponent({ - props: { - ...openProps([{ error: new Error('boom') }, {}]), - initialLoadFailed: true, - }, - }); - - expect(getByTestId('workflow-review-list-error')).toHaveTextContent("Couldn't load reviews"); - expect(queryAllByTestId('workflow-review-request-row')).toHaveLength(0); - - await fireEvent.click(getByTestId('workflow-review-list-retry')); - - expect(emitted().retryActiveTab).toEqual([[]]); - }); - - it('shows a healthy sibling and gives the failed section its own retry', async () => { - const { getByTestId, getAllByTestId, queryByTestId, emitted } = renderComponent({ - props: { - ...openProps([{ error: new Error('boom') }, { items: [makeItem()] }]), - initialLoadFailed: true, - }, - }); - - expect(queryByTestId('workflow-review-list-error')).not.toBeInTheDocument(); - expect(getAllByTestId('workflow-review-request-row')).toHaveLength(1); - expect(getByTestId('workflow-review-section-error').dataset.section).toBe('waiting'); - - await fireEvent.click(getByTestId('workflow-review-section-retry')); - - expect(emitted().retry).toEqual([['waiting']]); - }); - - it('keeps loaded rows and the sibling section visible and emits retry for that section', async () => { - const { getByTestId, getAllByTestId, emitted } = renderComponent({ - props: openProps([ - { items: [makeItem()], error: new Error('boom') }, - { items: [makeItem({ id: 'req-2' })] }, - ]), - }); - - expect(getAllByTestId('workflow-review-request-row')).toHaveLength(2); - expect(getByTestId('workflow-review-section-error').dataset.section).toBe('waiting'); - - await fireEvent.click(getByTestId('workflow-review-section-retry')); - - expect(emitted().retry).toEqual([['waiting']]); - }); - }); -}); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.vue deleted file mode 100644 index 484f0de5f63..00000000000 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewRequestsSidebar.vue +++ /dev/null @@ -1,527 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.test.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.test.ts index f35b3054a75..2e88a3ddf42 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.test.ts +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.test.ts @@ -12,7 +12,7 @@ const router = createRouter({ history: createMemoryHistory(), routes: [ { - path: '/reviews/:reviewRequestId?', + path: '/inbox/reviews/:reviewId', name: WORKFLOW_REVIEW_REQUESTS_VIEW, component: { template: '
' }, }, @@ -44,7 +44,7 @@ describe('WorkflowReviewSubmittedDialog', () => { expect(getByRole('link', { name: 'your submission' })).toHaveAttribute( 'href', - '/reviews/review-1', + '/inbox/reviews/review-1', ); }); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.vue index 49fecfd2019..e7b4080a858 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.vue +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewSubmittedDialog.vue @@ -42,7 +42,7 @@ const { submittedDialogDismissed } = useWorkflowReviewDialogPreferences(); {{ i18n.baseText('workflowReviews.submitted.description.submission') }} diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts index 5c1ac41f0b7..ba4c1986888 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.test.ts @@ -50,7 +50,7 @@ const router = createRouter({ history: createMemoryHistory(), routes: [ { - path: '/reviews/:reviewRequestId?', + path: '/inbox/reviews/:reviewId', name: WORKFLOW_REVIEW_REQUESTS_VIEW, component: { template: '
' }, }, @@ -129,7 +129,10 @@ describe('WorkflowUpdateReviewDialog', () => { it('links to the open review', async () => { const { getByRole } = await renderDialog(); - expect(getByRole('link', { name: 'open review' })).toHaveAttribute('href', '/reviews/review-1'); + expect(getByRole('link', { name: 'open review' })).toHaveAttribute( + 'href', + '/inbox/reviews/review-1', + ); }); it('stays open without prefilling when no open review is known yet', async () => { diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue index b16f7100146..e31717cb5c7 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue @@ -257,7 +257,7 @@ const submit = async () => { v-if="workflowReviewRequestId" :to="{ name: WORKFLOW_REVIEW_REQUESTS_VIEW, - params: { reviewRequestId: workflowReviewRequestId }, + params: { reviewId: workflowReviewRequestId }, }" > {{ i18n.baseText('workflowReviews.updateReview.description.review') }} diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/constants.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/constants.ts index 716705b7b3f..80e44130ed8 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/constants.ts +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/constants.ts @@ -1,42 +1,4 @@ -import type { ComputedRef, InjectionKey, Ref } from 'vue'; - -export const WORKFLOW_REVIEW_REQUESTS_VIEW = 'WorkflowReviewRequestsView'; - -/** What a feed entry may say about a linked workflow: its live name and its pinned version. */ -export type ReviewLinkedWorkflowContext = { - workflowName: string; - pinnedVersionId: string | null; - /** The user-given version name, e.g. "Release candidate"; null when never named or pruned. */ - pinnedVersionName: string | null; -}; - -/** - * The review's linked workflows, keyed by workflow id. Provided by the detail surface, read - * by feed entries that mention a workflow. Resolved at read time on purpose: feed payloads - * are immutable and never snapshot names, so a rename shows up everywhere at once. A workflow - * deleted since the entry was written has no row here; the entry copy degrades. - */ -export const ReviewLinkedWorkflowsKey: InjectionKey< - ComputedRef> -> = Symbol('reviewLinkedWorkflows'); - -/** The detail pane's scroll area, which the activity feed scrolls in. */ -export const ReviewDetailScrollContainerKey: InjectionKey>> = - Symbol('reviewDetailScrollContainer'); - -/** - * Routing contract for the review inbox. - * - * Path: /reviews/:reviewRequestId? - * - `:reviewRequestId` is the open review (deep-linkable). Absent = inbox - * with nothing selected. Selection always navigates via router.replace. - * - * Query params hold filter state; adding a filter must not require a routing - * change — reserve the key here, hydrate it in the view, and write it with - * router.replace({ query }) preserving params. - * - `state`: 'open' | 'closed'. Default 'open' is omitted from the URL. - * - `tab`: 'activity' | 'changes' — detail-pane tab. Default 'activity' is - * omitted from the URL. - * - Reserved for later filters: `q`, `projectId`, `author`, `reviewer`. - */ -export const REVIEW_INBOX_QUERY_PARAM = { state: 'state', tab: 'tab' } as const; +export { + WORKFLOW_REVIEW_REQUESTS_VIEW, + REVIEW_INBOX_QUERY_PARAM, +} from '@n8n/frontend-module-inbox'; diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/module.descriptor.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/module.descriptor.ts deleted file mode 100644 index 6ec75c19e16..00000000000 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/module.descriptor.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineFrontendModule } from '@n8n/frontend-module-sdk'; - -import { VIEWS } from '@/app/constants'; - -import { useWorkflowReviewsFeature } from './composables/useWorkflowReviewsFeature'; -import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './constants'; - -const WorkflowReviewRequestsView = async () => - await import('./views/WorkflowReviewRequestsView.vue'); - -export const WorkflowReviewsModule = defineFrontendModule({ - id: 'workflow-reviews', - name: 'Workflow Reviews', - description: 'Cross-project workflow review inbox.', - icon: 'message-square-text', - routes: [ - { - path: '/reviews/:reviewRequestId?', - name: WORKFLOW_REVIEW_REQUESTS_VIEW, - component: WorkflowReviewRequestsView, - beforeEnter() { - return ( - useWorkflowReviewsFeature().isWorkflowReviewsEnabled.value || { - name: VIEWS.HOMEPAGE, - } - ); - }, - meta: { - layout: 'default', - middleware: ['authenticated', 'custom'], - }, - }, - ], -}); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.test.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.test.ts deleted file mode 100644 index 473abb4bd72..00000000000 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.test.ts +++ /dev/null @@ -1,800 +0,0 @@ -import type { - ListWorkflowReviewInboxResponse, - WorkflowReviewInboxItem, - WorkflowReviewRequestDetail, -} from '@n8n/api-types'; -import { createPinia, setActivePinia } from 'pinia'; -import { ResponseError } from '@n8n/rest-api-client'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import * as workflowReviewsApi from './workflowReviews.api'; -import { useReviewInboxStore } from './reviewInbox.store'; - -vi.mock('./workflowReviews.api'); - -type SectionKey = 'waiting' | 'authored' | 'closed'; - -function makeItem(overrides: Partial = {}): WorkflowReviewInboxItem { - return { - id: 'req-1', - projectId: 'proj-1', - title: 'Review', - workflowName: 'My workflow', - workflowVersionId: null, - requester: null, - authors: [], - reviewers: [], - decision: 'pending', - state: 'open', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - ...overrides, - }; -} - -function page( - data: WorkflowReviewInboxItem[], - overrides: Partial = {}, -): ListWorkflowReviewInboxResponse { - return { data, nextCursor: null, hasMore: false, ...overrides }; -} - -const emptyPage = () => page([]); - -/** Route each mocked inbox request to the section it belongs to. */ -function sectionOf(params: workflowReviewsApi.FetchWorkflowReviewInboxParams): SectionKey { - if (params.state === 'closed') return 'closed'; - return params.category === 'authored' ? 'authored' : 'waiting'; -} - -function mockInbox( - responses: Partial< - Record< - SectionKey, - ListWorkflowReviewInboxResponse | (() => Promise) - > - >, -) { - vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockImplementation( - async (_context, params) => { - const response = responses[sectionOf(params)]; - if (typeof response === 'function') return await response(); - return response ?? emptyPage(); - }, - ); -} - -function mockSummary(open: number, closed: number) { - vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({ open, closed }); -} - -function inboxCallsFor(section: SectionKey) { - return vi - .mocked(workflowReviewsApi.fetchWorkflowReviewInbox) - .mock.calls.filter(([, params]) => sectionOf(params) === section); -} - -describe('useReviewInboxStore', () => { - beforeEach(() => { - setActivePinia(createPinia()); - vi.resetAllMocks(); - }); - - describe('summary and section fetching', () => { - it('loads the summary and both open sections', async () => { - mockSummary(3, 12); - mockInbox({ - waiting: page([makeItem({ id: 'waiting-1' })]), - authored: page([makeItem({ id: 'authored-1' })]), - }); - - const store = useReviewInboxStore(); - await Promise.all([store.fetchSummary(), store.fetchActiveTab()]); - - expect(workflowReviewsApi.fetchWorkflowReviewInboxSummary).toHaveBeenCalledTimes(1); - expect(store.openCount).toBe(3); - expect(store.closedCount).toBe(12); - expect(inboxCallsFor('waiting')).toHaveLength(1); - expect(inboxCallsFor('authored')).toHaveLength(1); - expect(inboxCallsFor('closed')).toHaveLength(0); - expect(store.sections.waiting.items).toEqual([expect.objectContaining({ id: 'waiting-1' })]); - expect(store.sections.authored.items).toEqual([ - expect.objectContaining({ id: 'authored-1' }), - ]); - }); - - it('requests the closed tab without a category', async () => { - mockInbox({ closed: page([makeItem({ id: 'closed-1', state: 'closed' })]) }); - - const store = useReviewInboxStore(); - await store.setActiveTab('closed'); - - expect(store.activeTab).toBe('closed'); - expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenCalledTimes(1); - expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ state: 'closed', category: undefined }), - ); - expect(store.sections.closed.items).toHaveLength(1); - }); - - it('fetches the active tab without waiting for the summary', async () => { - let resolveSummary!: (value: { open: number; closed: number }) => void; - vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockImplementationOnce( - async () => - await new Promise<{ open: number; closed: number }>((resolve) => { - resolveSummary = resolve; - }), - ); - mockInbox({}); - - const store = useReviewInboxStore(); - const summary = store.fetchSummary(); - await store.fetchActiveTab(); - - expect(inboxCallsFor('waiting')).toHaveLength(1); - expect(inboxCallsFor('authored')).toHaveLength(1); - expect(store.isEmpty).toBe(true); - - resolveSummary({ open: 0, closed: 0 }); - await summary; - }); - - it('stores each section as soon as its own request settles', async () => { - let resolveAuthored!: (value: ListWorkflowReviewInboxResponse) => void; - mockInbox({ - waiting: page([makeItem({ id: 'waiting-1' })]), - authored: async () => - await new Promise((resolve) => { - resolveAuthored = resolve; - }), - }); - - const store = useReviewInboxStore(); - const fetching = store.fetchActiveTab(); - - await vi.waitFor(() => { - expect(store.sections.waiting.items).toHaveLength(1); - }); - expect(store.sections.waiting.loading).toBe(false); - expect(store.sections.authored.loading).toBe(true); - expect(store.isLoadingActiveTab).toBe(true); - - resolveAuthored(page([makeItem({ id: 'authored-1' })])); - await fetching; - expect(store.sections.authored.items).toHaveLength(1); - }); - - it('does not apply summary results after reset', async () => { - let resolveSummary!: (value: { open: number; closed: number }) => void; - vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockImplementationOnce( - async () => - await new Promise<{ open: number; closed: number }>((resolve) => { - resolveSummary = resolve; - }), - ); - - const store = useReviewInboxStore(); - const summary = store.fetchSummary(); - store.reset(); - resolveSummary({ open: 1, closed: 0 }); - await summary; - - expect(store.openCount).toBeNull(); - expect(store.closedCount).toBeNull(); - }); - - it('ignores a stale response from an earlier fetch of the same section', async () => { - let resolveFirst!: (value: ListWorkflowReviewInboxResponse) => void; - let waitingCalls = 0; - mockInbox({ - waiting: async () => { - waitingCalls += 1; - if (waitingCalls === 1) { - return await new Promise((resolve) => { - resolveFirst = resolve; - }); - } - return page([makeItem({ id: 'fresh' })]); - }, - }); - - const store = useReviewInboxStore(); - const firstFetch = store.fetchActiveTab(); - await store.fetchActiveTab(); - - expect(store.sections.waiting.items).toEqual([expect.objectContaining({ id: 'fresh' })]); - - resolveFirst(page([makeItem({ id: 'stale' })])); - await firstFetch; - - expect(store.sections.waiting.items).toEqual([expect.objectContaining({ id: 'fresh' })]); - }); - }); - - describe('pagination', () => { - it('paginates each section with its own cursor', async () => { - mockInbox({ - waiting: page([makeItem({ id: 'waiting-1' })], { - nextCursor: 'waiting-cursor', - hasMore: true, - }), - authored: page([makeItem({ id: 'authored-1' })], { - nextCursor: 'authored-cursor', - hasMore: true, - }), - }); - - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - mockInbox({ - waiting: page([makeItem({ id: 'waiting-2' })]), - authored: page([makeItem({ id: 'authored-2' })]), - }); - await store.loadMore('waiting'); - - expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ category: 'waiting', cursor: 'waiting-cursor' }), - ); - expect(store.sections.waiting.items.map((item) => item.id)).toEqual([ - 'waiting-1', - 'waiting-2', - ]); - // Untouched: load-more appends to one section only. - expect(store.sections.authored.items.map((item) => item.id)).toEqual(['authored-1']); - expect(store.sections.authored.hasMore).toBe(true); - }); - - it('ignores a duplicate load-more while one is in flight', async () => { - mockInbox({ - waiting: page([makeItem({ id: 'waiting-1' })], { nextCursor: 'cursor', hasMore: true }), - }); - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - let resolveMore!: (value: ListWorkflowReviewInboxResponse) => void; - mockInbox({ - waiting: async () => - await new Promise((resolve) => { - resolveMore = resolve; - }), - }); - - const first = store.loadMore('waiting'); - await store.loadMore('waiting'); - expect(inboxCallsFor('waiting')).toHaveLength(2); - - resolveMore(page([makeItem({ id: 'waiting-2' })])); - await first; - expect(store.sections.waiting.items).toHaveLength(2); - }); - - it('does nothing when the section has no further pages', async () => { - mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]) }); - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - await store.loadMore('waiting'); - - expect(inboxCallsFor('waiting')).toHaveLength(1); - }); - - it('clears loadingMore when a refetch invalidates pagination', async () => { - mockInbox({ - waiting: page([makeItem({ id: 'waiting-1' })], { nextCursor: 'cursor', hasMore: true }), - }); - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - let resolveMore!: (value: ListWorkflowReviewInboxResponse) => void; - mockInbox({ - waiting: async () => - await new Promise((resolve) => { - resolveMore = resolve; - }), - }); - const loadMore = store.loadMore('waiting'); - await vi.waitFor(() => { - expect(store.sections.waiting.loadingMore).toBe(true); - }); - - mockInbox({ closed: emptyPage() }); - await store.setActiveTab('closed'); - // setActiveTab only refetches the closed slice, so reset the stalled one directly. - mockInbox({ waiting: emptyPage() }); - await store.setActiveTab('open'); - expect(store.sections.waiting.loadingMore).toBe(false); - - resolveMore(page([makeItem({ id: 'never-applied' })])); - await loadMore; - expect(store.sections.waiting.loadingMore).toBe(false); - expect(store.sections.waiting.items).toEqual([]); - }); - }); - - describe('errors', () => { - it('settles the active tab with one initial error when a section fails', async () => { - mockInbox({ - waiting: async () => { - throw new Error('network'); - }, - authored: page([makeItem({ id: 'authored-1' })]), - }); - - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - expect(store.sections.waiting.error).toEqual(new Error('network')); - expect(store.sections.authored.error).toBeNull(); - expect(store.sections.authored.items).toHaveLength(1); - expect(store.activeTabInitialLoadFailed).toBe(true); - }); - - it('keeps rows and the cursor after a failed load-more and retries the same page', async () => { - mockInbox({ - waiting: page([makeItem({ id: 'waiting-1' })], { nextCursor: 'cursor', hasMore: true }), - }); - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - mockInbox({ - waiting: async () => { - throw new Error('boom'); - }, - }); - await store.loadMore('waiting'); - - expect(store.sections.waiting.items).toHaveLength(1); - expect(store.sections.waiting.nextCursor).toBe('cursor'); - expect(store.sections.waiting.error).toEqual(new Error('boom')); - - mockInbox({ waiting: page([makeItem({ id: 'waiting-2' })]) }); - await store.retry('waiting'); - - expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ category: 'waiting', cursor: 'cursor' }), - ); - expect(store.sections.waiting.items.map((item) => item.id)).toEqual([ - 'waiting-1', - 'waiting-2', - ]); - expect(store.sections.waiting.error).toBeNull(); - }); - - it('retries the first page when the initial section fetch failed', async () => { - mockInbox({ - waiting: async () => { - throw new Error('down'); - }, - }); - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]) }); - await store.retry('waiting'); - - expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ category: 'waiting', cursor: undefined }), - ); - expect(store.sections.waiting.items).toHaveLength(1); - }); - - it('does not treat a failed section fetch as an empty inbox', async () => { - mockInbox({ - waiting: async () => { - throw new Error('network'); - }, - }); - - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - expect(store.isEmpty).toBe(false); - // The failure belongs to the section that suffered it, and to it alone. - expect(store.sections.waiting.error).toEqual(new Error('network')); - expect(store.sections.authored.error).toBeNull(); - }); - - it('leaves counts unknown when the summary fails', async () => { - vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockRejectedValue( - new Error('summary down'), - ); - - const store = useReviewInboxStore(); - await store.fetchSummary(); - - expect(store.openCount).toBeNull(); - expect(store.closedCount).toBeNull(); - }); - - it('loads sections independently when the summary fails', async () => { - mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]), authored: emptyPage() }); - vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockRejectedValue( - new Error('summary down'), - ); - - const store = useReviewInboxStore(); - await Promise.all([store.fetchSummary(), store.fetchActiveTab()]); - - expect(store.sections.waiting.items).toHaveLength(1); - expect(store.isEmpty).toBe(false); - expect(store.openCount).toBeNull(); - expect(store.closedCount).toBeNull(); - }); - }); - - describe('isEmpty', () => { - it('is true on the open tab only when both sections are empty', async () => { - mockInbox({ waiting: page([makeItem({ id: 'waiting-1' })]), authored: emptyPage() }); - - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - expect(store.isEmpty).toBe(false); - - mockInbox({ waiting: emptyPage(), authored: emptyPage() }); - await store.fetchActiveTab(); - expect(store.isEmpty).toBe(true); - }); - - it('follows the closed slice on the closed tab', async () => { - mockInbox({ closed: emptyPage() }); - - const store = useReviewInboxStore(); - await store.setActiveTab('closed'); - - expect(store.isEmpty).toBe(true); - }); - }); - - describe('active tab state', () => { - it('reports rows from either open section', async () => { - mockInbox({ waiting: emptyPage(), authored: page([makeItem({ id: 'authored-1' })]) }); - - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - expect(store.hasItemsInActiveTab).toBe(true); - expect(store.isEmpty).toBe(false); - expect(store.isLoadingActiveTab).toBe(false); - }); - - it('reports one initial failure once both sections have settled', async () => { - mockInbox({ - waiting: async () => { - throw new Error('network'); - }, - authored: emptyPage(), - }); - - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - expect(store.isLoadingActiveTab).toBe(false); - expect(store.isEmpty).toBe(false); - expect(store.hasItemsInActiveTab).toBe(false); - expect(store.activeTabInitialLoadFailed).toBe(true); - }); - - it('loads until both open sections have settled', async () => { - let releaseWaiting = () => {}; - mockInbox({ - waiting: async () => { - await new Promise((resolve) => { - releaseWaiting = resolve; - }); - return { data: [], nextCursor: null, hasMore: false }; - }, - authored: emptyPage(), - }); - - const store = useReviewInboxStore(); - const fetching = store.fetchActiveTab(); - await vi.waitFor(() => expect(store.isLoadingActiveTab).toBe(true)); - expect(store.isEmpty).toBe(false); - - releaseWaiting(); - await fetching; - - expect(store.isLoadingActiveTab).toBe(false); - expect(store.isEmpty).toBe(true); - }); - }); - - describe('decideOnReview', () => { - async function seedOpenSections() { - mockSummary(2, 5); - mockInbox({ - waiting: page([makeItem({ id: 'req-1' })]), - authored: page([makeItem({ id: 'req-2', title: 'Mine' })]), - }); - - const store = useReviewInboxStore(); - await Promise.all([store.fetchSummary(), store.fetchActiveTab()]); - return store; - } - - it('removes an approved item from its section, adjusts counts, and patches detail', async () => { - const store = await seedOpenSections(); - store.detail = createDetail(); - vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockResolvedValue({ - id: 'req-1', - state: 'closed', - decision: 'approved', - workflowVersionId: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }); - - await store.decideOnReview('req-1', { decision: 'approved' }); - - expect(store.sections.waiting.items).toEqual([]); - expect(store.sections.authored.items).toHaveLength(1); - expect(store.openCount).toBe(1); - expect(store.closedCount).toBe(6); - expect(store.detail).toEqual( - expect.objectContaining({ state: 'closed', decision: 'approved' }), - ); - }); - - it('decides an authored review without moving it between sections', async () => { - const store = await seedOpenSections(); - vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockResolvedValue({ - id: 'req-2', - state: 'open', - decision: 'changes_requested', - workflowVersionId: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }); - - await store.decideOnReview('req-2', { - decision: 'changes_requested', - note: 'needs work', - }); - - expect(workflowReviewsApi.decideWorkflowReviewRequest).toHaveBeenCalledWith( - expect.anything(), - 'req-2', - { decision: 'changes_requested', note: 'needs work' }, - ); - expect(store.sections.waiting.items.map((item) => item.id)).toEqual(['req-1']); - expect(store.sections.authored.items).toEqual([ - expect.objectContaining({ id: 'req-2', state: 'open', decision: 'changes_requested' }), - ]); - expect(store.openCount).toBe(2); - expect(store.closedCount).toBe(5); - }); - - it('returns the response so callers can surface the auto-publish outcome', async () => { - const store = await seedOpenSections(); - vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockResolvedValue({ - id: 'req-1', - state: 'closed', - decision: 'approved', - workflowVersionId: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - autoPublish: { status: 'published' }, - }); - - const response = await store.decideOnReview('req-1', { decision: 'approved' }); - - expect(response.autoPublish).toEqual({ status: 'published' }); - }); - - it('rethrows an API error and leaves the state untouched', async () => { - const store = await seedOpenSections(); - vi.mocked(workflowReviewsApi.decideWorkflowReviewRequest).mockRejectedValue( - new Error('forbidden'), - ); - - await expect(store.decideOnReview('req-1', { decision: 'approved' })).rejects.toThrow( - 'forbidden', - ); - - expect(store.sections.waiting.items).toEqual([ - expect.objectContaining({ state: 'open', decision: 'pending' }), - ]); - expect(store.openCount).toBe(2); - expect(store.closedCount).toBe(5); - }); - }); - - describe('findItemById', () => { - it('resolves items from either open section and returns null otherwise', async () => { - mockInbox({ - waiting: page([makeItem({ id: 'req-1' })]), - authored: page([makeItem({ id: 'req-2' })]), - }); - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - expect(store.findItemById('req-1')).toEqual(expect.objectContaining({ id: 'req-1' })); - expect(store.findItemById('req-2')).toEqual(expect.objectContaining({ id: 'req-2' })); - expect(store.findItemById('missing')).toBeNull(); - }); - }); - - describe('detail', () => { - it('loads review detail', async () => { - const expected = createDetail(); - vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockResolvedValue(expected); - const store = useReviewInboxStore(); - - await store.fetchDetail('req-1'); - - expect(store.detail).toEqual(expected); - expect(store.detailLoading).toBe(false); - expect(store.detailNotFound).toBe(false); - }); - - it('shows not found without throwing for a 404', async () => { - vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockRejectedValue( - new ResponseError('gone', { httpStatusCode: 404 }), - ); - const store = useReviewInboxStore(); - - await expect(store.fetchDetail('missing')).resolves.toBeUndefined(); - - expect(store.detail).toBeNull(); - expect(store.detailNotFound).toBe(true); - expect(store.detailLoading).toBe(false); - }); - - it('rethrows a non-404 detail error', async () => { - vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockRejectedValue( - new ResponseError('forbidden', { httpStatusCode: 403 }), - ); - const store = useReviewInboxStore(); - - await expect(store.fetchDetail('req-1')).rejects.toThrow('forbidden'); - - expect(store.detailNotFound).toBe(false); - }); - - it('does not suppress the section empty states after a failed detail fetch', async () => { - mockInbox({ waiting: emptyPage(), authored: emptyPage() }); - vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail).mockRejectedValue( - new ResponseError('boom', { httpStatusCode: 500 }), - ); - const store = useReviewInboxStore(); - await store.fetchActiveTab(); - - await expect(store.fetchDetail('req-1')).rejects.toThrow('boom'); - - expect(store.isEmpty).toBe(true); - expect(store.sections.waiting.error).toBeNull(); - }); - - it('ignores a stale detail response', async () => { - let resolveFirst!: (detail: WorkflowReviewRequestDetail) => void; - vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail) - .mockImplementationOnce( - async () => - await new Promise((resolve) => { - resolveFirst = resolve; - }), - ) - .mockResolvedValueOnce({ ...createDetail(), id: 'req-2', title: 'Newer review' }); - const store = useReviewInboxStore(); - - const firstFetch = store.fetchDetail('req-1'); - await store.fetchDetail('req-2'); - resolveFirst(createDetail()); - await firstFetch; - - expect(store.detail).toEqual(expect.objectContaining({ id: 'req-2', title: 'Newer review' })); - }); - - it('keeps detail visible while refetching the same review', async () => { - const first = createDetail(); - let resolveSecond!: (detail: WorkflowReviewRequestDetail) => void; - vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail) - .mockResolvedValueOnce(first) - .mockImplementationOnce( - async () => - await new Promise((resolve) => { - resolveSecond = resolve; - }), - ); - const store = useReviewInboxStore(); - await store.fetchDetail('req-1'); - - const secondFetch = store.fetchDetail('req-1'); - - expect(store.detail).toEqual(first); - expect(store.detailLoading).toBe(false); - - resolveSecond({ ...first, title: 'Updated title' }); - await secondFetch; - - expect(store.detail).toEqual(expect.objectContaining({ title: 'Updated title' })); - }); - - it('clears not found when a later refetch of the same review succeeds', async () => { - const detail = createDetail(); - vi.mocked(workflowReviewsApi.fetchWorkflowReviewRequestDetail) - .mockResolvedValueOnce(detail) - .mockRejectedValueOnce(new ResponseError('gone', { httpStatusCode: 404 })) - .mockResolvedValueOnce({ ...detail, title: 'Back again' }); - const store = useReviewInboxStore(); - await store.fetchDetail('req-1'); - - await store.fetchDetail('req-1'); - expect(store.detailNotFound).toBe(true); - - await store.fetchDetail('req-1'); - - expect(store.detail).toEqual(expect.objectContaining({ title: 'Back again' })); - expect(store.detailNotFound).toBe(false); - }); - - it('does not clear the detail when switching tabs', async () => { - mockInbox({ closed: emptyPage() }); - const store = useReviewInboxStore(); - store.detail = createDetail(); - - await store.setActiveTab('closed'); - - expect(store.detail).toEqual(expect.objectContaining({ id: 'req-1' })); - }); - - it('clears and resets detail state', () => { - const store = useReviewInboxStore(); - store.detail = createDetail(); - store.detailLoading = true; - store.detailNotFound = true; - - store.clearDetail(); - - expect(store.detail).toBeNull(); - expect(store.detailLoading).toBe(false); - expect(store.detailNotFound).toBe(false); - }); - }); - - it('resets every section', async () => { - mockSummary(1, 1); - mockInbox({ - waiting: page([makeItem({ id: 'req-1' })], { nextCursor: 'cursor', hasMore: true }), - authored: page([makeItem({ id: 'req-2' })]), - }); - const store = useReviewInboxStore(); - await Promise.all([store.fetchSummary(), store.fetchActiveTab()]); - - store.reset(); - - expect(store.sections.waiting.items).toEqual([]); - expect(store.sections.waiting.nextCursor).toBeNull(); - expect(store.sections.waiting.hasMore).toBe(false); - expect(store.sections.authored.items).toEqual([]); - expect(store.sections.closed.items).toEqual([]); - expect(store.activeTab).toBe('open'); - expect(store.openCount).toBeNull(); - expect(store.closedCount).toBeNull(); - }); -}); - -function createDetail(): WorkflowReviewRequestDetail { - return { - id: 'req-1', - projectId: 'proj-1', - title: 'Review', - requester: null, - authors: [], - reviewers: [], - decision: 'pending', - state: 'open', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - description: null, - workflows: [], - viewerCanDecide: true, - viewerDecisionIneligibilityReason: null, - viewerCanComment: true, - }; -} diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.ts deleted file mode 100644 index ec3b34ded7c..00000000000 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/reviewInbox.store.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { defineStore } from 'pinia'; -import type { - ListWorkflowReviewInboxResponse, - WorkflowReviewInboxCategory, - WorkflowReviewRequestDetail, - WorkflowReviewRequestState, - WorkflowReviewInboxItem, -} from '@n8n/api-types'; -import { ResponseError } from '@n8n/rest-api-client'; -import { computed, ref } from 'vue'; - -import { useRootStore } from '@n8n/stores/useRootStore'; - -import { - decideWorkflowReviewRequest, - fetchWorkflowReviewInbox, - fetchWorkflowReviewInboxSummary, - fetchWorkflowReviewRequestDetail, - type WorkflowReviewDecisionInput, -} from './workflowReviews.api'; -import { toError } from './workflowReviews.utils'; - -const DEFAULT_LIMIT = 15; - -/** - * The open tab is split by authorship, the closed tab stays flat — three - * independently paginated lists in total. - */ -export type ReviewInboxSectionKey = 'waiting' | 'authored' | 'closed'; - -type RequestPage = (cursor?: string) => Promise; - -/** One keyset-paginated list with its own pagination and load-more state. */ -function createInboxListSlice(requestPage: RequestPage) { - const items = ref([]); - const nextCursor = ref(null); - const hasMore = ref(false); - const loading = ref(false); - const loadingMore = ref(false); - const error = ref(null); - // Which request failed, so `retry` repeats that one — a failed load-more keeps - // its rows and cursor, so retrying it must re-request the same page. - const failedRequest = ref<'list' | 'loadMore' | null>(null); - - let requestSeq = 0; - - function applyResponse( - response: ListWorkflowReviewInboxResponse, - { append }: { append: boolean }, - ): void { - items.value = append ? [...items.value, ...response.data] : response.data; - nextCursor.value = response.nextCursor; - hasMore.value = response.hasMore; - } - - async function fetchList() { - const seq = ++requestSeq; - - items.value = []; - nextCursor.value = null; - hasMore.value = false; - // Invalidate any in-flight loadMore so pagination is not stuck. - loadingMore.value = false; - loading.value = true; - error.value = null; - failedRequest.value = null; - - try { - const response = await requestPage(); - if (seq !== requestSeq) return; - applyResponse(response, { append: false }); - } catch (e) { - if (seq !== requestSeq) return; - error.value = toError(e); - failedRequest.value = 'list'; - } finally { - if (seq === requestSeq) { - loading.value = false; - } - } - } - - async function loadMore() { - if (loading.value || loadingMore.value || !hasMore.value || !nextCursor.value) { - return; - } - - const seq = ++requestSeq; - const cursor = nextCursor.value; - loadingMore.value = true; - error.value = null; - failedRequest.value = null; - - try { - const response = await requestPage(cursor); - if (seq !== requestSeq) return; - applyResponse(response, { append: true }); - } catch (e) { - if (seq !== requestSeq) return; - // Rows and cursor are deliberately left in place, so the retry asks for - // the same page instead of restarting the section. - error.value = toError(e); - failedRequest.value = 'loadMore'; - } finally { - if (seq === requestSeq) { - loadingMore.value = false; - } - } - } - - async function retry() { - if (failedRequest.value === 'loadMore') { - await loadMore(); - return; - } - await fetchList(); - } - - function findItem(id: string): WorkflowReviewInboxItem | null { - return items.value.find((candidate) => candidate.id === id) ?? null; - } - - function removeItem(id: string): void { - items.value = items.value.filter((candidate) => candidate.id !== id); - } - - function reset(): void { - requestSeq += 1; - items.value = []; - nextCursor.value = null; - hasMore.value = false; - loading.value = false; - loadingMore.value = false; - error.value = null; - failedRequest.value = null; - } - - /** Settled with nothing to show — never "empty" while loading or after a failure. */ - const isEmpty = computed( - () => !loading.value && error.value === null && items.value.length === 0, - ); - const initialLoadFailed = computed(() => failedRequest.value === 'list'); - - return { - items, - nextCursor, - hasMore, - loading, - loadingMore, - error, - isEmpty, - initialLoadFailed, - fetchList, - loadMore, - retry, - findItem, - removeItem, - reset, - }; -} - -export const useReviewInboxStore = defineStore('workflowReviewInbox', () => { - const rootStore = useRootStore(); - - const openCount = ref(null); - const closedCount = ref(null); - const detail = ref(null); - const detailLoading = ref(false); - const detailNotFound = ref(false); - // The view hydrates this from `?state=` so the first list fetch uses the URL state. - const activeTab = ref('open'); - - let summaryRequestSeq = 0; - let detailRequestSeq = 0; - - function requestPage( - state: WorkflowReviewRequestState, - category?: WorkflowReviewInboxCategory, - ): RequestPage { - return async (cursor?: string) => - await fetchWorkflowReviewInbox(rootStore.restApiContext, { - state, - category, - limit: DEFAULT_LIMIT, - cursor, - }); - } - - /** - * Plain object of slices, not a `ref`/`reactive` wrapper: the slice internals - * stay refs and Pinia unwraps them on access, so consumers read - * `store.sections.waiting.items` directly. `storeToRefs` does not reach into - * this object. - */ - const sections = { - waiting: createInboxListSlice(requestPage('open', 'waiting')), - authored: createInboxListSlice(requestPage('open', 'authored')), - closed: createInboxListSlice(requestPage('closed')), - }; - - const allSlices = [sections.waiting, sections.authored, sections.closed]; - - const activeSlices = computed(() => - activeTab.value === 'closed' ? [sections.closed] : [sections.waiting, sections.authored], - ); - - const hasItemsInActiveTab = computed(() => - activeSlices.value.some((slice) => slice.items.value.length > 0), - ); - - const isLoadingActiveTab = computed(() => - activeSlices.value.some((slice) => slice.loading.value), - ); - - const activeTabInitialLoadFailed = computed(() => - activeSlices.value.some((slice) => slice.initialLoadFailed.value), - ); - - // Settled with nothing to show. A failed slice is not treated as empty. - const isEmpty = computed(() => activeSlices.value.every((slice) => slice.isEmpty.value)); - - /** - * Both open sections start together and settle before the tab is rendered. - * Each slice still owns its pagination state after the initial load. - */ - async function fetchActiveTab() { - if (activeTab.value === 'closed') { - await sections.closed.fetchList(); - return; - } - - await Promise.all([sections.waiting.fetchList(), sections.authored.fetchList()]); - } - - async function fetchSummary() { - const requestSeq = ++summaryRequestSeq; - - try { - const summary = await fetchWorkflowReviewInboxSummary(rootStore.restApiContext); - if (requestSeq !== summaryRequestSeq) return; - - openCount.value = summary.open; - closedCount.value = summary.closed; - } catch { - // Counts enhance the tabs and no-selection state, but never block the lists. - } - } - - async function loadMore(section: ReviewInboxSectionKey) { - await sections[section].loadMore(); - } - - async function retry(section: ReviewInboxSectionKey) { - await sections[section].retry(); - } - - async function setActiveTab(tab: WorkflowReviewRequestState) { - if (activeTab.value === tab) return; - activeTab.value = tab; - await fetchActiveTab(); - } - - function findItemById(id: string): WorkflowReviewInboxItem | null { - for (const slice of allSlices) { - const item = slice.findItem(id); - if (item) return item; - } - return null; - } - - async function fetchDetail(id: string) { - const requestSeq = ++detailRequestSeq; - const switchedReview = detail.value?.id !== id; - if (switchedReview) { - detail.value = null; - detailLoading.value = true; - detailNotFound.value = false; - } - - try { - const response = await fetchWorkflowReviewRequestDetail(rootStore.restApiContext, id); - if (requestSeq !== detailRequestSeq) { - return; - } - detail.value = response; - detailNotFound.value = false; - } catch (e) { - if (requestSeq !== detailRequestSeq) { - return; - } - if (e instanceof ResponseError && e.httpStatusCode === 404) { - detailNotFound.value = true; - return; - } - // deliberately not stored on a list-scoped error ref: those gate the - // section empty states, so a detail failure would suppress them for the - // rest of the session. - throw e; - } finally { - if (requestSeq === detailRequestSeq) { - detailLoading.value = false; - } - } - } - - function clearDetail() { - detailRequestSeq += 1; - detail.value = null; - detailLoading.value = false; - detailNotFound.value = false; - } - - /** - * Submit a decision and patch the affected item in place. Approving closes - * the request; the closed tab refetches on activation and picks it up there. - * Decisions never change authorship, so an item never moves between the - * waiting and authored sections. Returns the response so callers can surface - * the auto-publish outcome. - */ - async function decideOnReview(id: string, input: WorkflowReviewDecisionInput) { - const summary = await decideWorkflowReviewRequest(rootStore.restApiContext, id, input); - - const item = findItemById(id); - if (item) { - item.decision = summary.decision; - item.state = summary.state; - item.updatedAt = summary.updatedAt; - } - - if (detail.value?.id === id) { - detail.value.decision = summary.decision; - detail.value.state = summary.state; - detail.value.updatedAt = summary.updatedAt; - } - - if (summary.state === 'closed') { - if (openCount.value !== null) openCount.value = Math.max(0, openCount.value - 1); - if (closedCount.value !== null) closedCount.value += 1; - } - - // The sections only show items matching the active tab filter. - if (item && item.state !== activeTab.value) { - for (const slice of allSlices) { - slice.removeItem(id); - } - } - - return summary; - } - - function reset() { - summaryRequestSeq += 1; - detailRequestSeq += 1; - openCount.value = null; - closedCount.value = null; - detail.value = null; - detailLoading.value = false; - detailNotFound.value = false; - activeTab.value = 'open'; - for (const slice of allSlices) { - slice.reset(); - } - } - - return { - openCount, - closedCount, - sections, - detail, - detailLoading, - detailNotFound, - activeTab, - isEmpty, - isLoadingActiveTab, - activeTabInitialLoadFailed, - hasItemsInActiveTab, - fetchSummary, - fetchActiveTab, - loadMore, - retry, - setActiveTab, - findItemById, - fetchDetail, - clearDetail, - decideOnReview, - reset, - }; -}); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.test.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.test.ts deleted file mode 100644 index 677f818763a..00000000000 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.test.ts +++ /dev/null @@ -1,969 +0,0 @@ -import type { - DecideWorkflowReviewRequestResponse, - WorkflowReviewInboxItem, - WorkflowReviewRequestDetail, -} from '@n8n/api-types'; -import { createTestingPinia } from '@pinia/testing'; -import { within } from '@testing-library/vue'; -import { createComponentRenderer } from '@/__tests__/render'; -import { mockedStore, waitAllPromises } from '@/__tests__/utils'; -import { useToast } from '@n8n/composables/useToast'; -import { createMemoryHistory, createRouter } from 'vue-router'; - -import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '../constants'; -import { useReviewActivityStore } from '../reviewActivity.store'; -import { useReviewInboxStore } from '../reviewInbox.store'; -import WorkflowReviewRequestsView from './WorkflowReviewRequestsView.vue'; - -vi.mock('@n8n/composables/useToast', () => ({ - useToast: vi.fn(), -})); - -vi.mock('@/app/composables/useDocumentTitle', () => ({ - useDocumentTitle: () => ({ - set: vi.fn(), - }), -})); - -// The changes sections pull in the whole workflow-diff canvas machinery, which -// is far too heavy for these routing/wiring tests. -vi.mock('@/features/workflow-reviews/components/WorkflowReviewChangesSection.vue', () => ({ - default: { - name: 'WorkflowReviewChangesSection', - props: ['workflow', 'state', 'decision'], - template: '
', - }, -})); - -const showError = vi.fn(); -const showMessage = vi.fn(); - -const router = createRouter({ - history: createMemoryHistory(), - routes: [ - { - path: '/reviews/:reviewRequestId?', - name: WORKFLOW_REVIEW_REQUESTS_VIEW, - component: { template: '
' }, - }, - { - path: '/:pathMatch(.*)*', - name: 'not-found', - component: { template: '
' }, - }, - ], -}); - -const renderComponent = createComponentRenderer(WorkflowReviewRequestsView, { - global: { - plugins: [router], - stubs: { - PageViewLayout: { - template: '
', - }, - WorkflowReviewRequestsSidebar: { - props: ['sections', 'loading', 'initialLoadFailed'], - template: ` -
-
`, - }, - // 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: ` -
-
`, - }, - }, - }, -}); - -describe('WorkflowReviewRequestsView', () => { - let store: ReturnType>; - let activityStore: ReturnType>; - - beforeEach(async () => { - createTestingPinia(); - showError.mockReset(); - showMessage.mockReset(); - vi.mocked(useToast).mockReturnValue({ showError, showMessage } as unknown as ReturnType< - typeof useToast - >); - await router.push('/reviews'); - await router.isReady(); - - store = mockedStore(useReviewInboxStore); - store.detail = null; - store.detailLoading = false; - store.detailNotFound = false; - store.activeTab = 'open'; - store.isEmpty = false; - store.isLoadingActiveTab = false; - store.activeTabInitialLoadFailed = false; - store.hasItemsInActiveTab = false; - store.openCount = null; - store.closedCount = null; - store.fetchSummary.mockResolvedValue(undefined); - store.fetchActiveTab.mockResolvedValue(undefined); - store.fetchDetail.mockResolvedValue(undefined); - store.setActiveTab.mockResolvedValue(undefined); - store.loadMore.mockResolvedValue(undefined); - store.retry.mockResolvedValue(undefined); - store.findItemById.mockReturnValue(null); - store.reset.mockClear(); - - activityStore = mockedStore(useReviewActivityStore); - activityStore.fetchFeed.mockResolvedValue(undefined); - }); - - it('loads the counts and active tab on mount', async () => { - renderComponent(); - await waitAllPromises(); - - expect(store.fetchSummary).toHaveBeenCalledTimes(1); - expect(store.fetchActiveTab).toHaveBeenCalledTimes(1); - }); - - it('shows loading while the active tab has not settled', async () => { - store.isLoadingActiveTab = true; - const { container, queryByTestId } = renderComponent(); - await waitAllPromises(); - - expect(container.querySelector('.n8n-loading')).toBeInTheDocument(); - expect(queryByTestId('workflow-reviews-empty-state')).not.toBeInTheDocument(); - }); - - it('does not fetch or select a review on the bare inbox path', async () => { - store.hasItemsInActiveTab = true; - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect(store.fetchDetail).not.toHaveBeenCalled(); - expect(getByTestId('workflow-reviews-no-selection')).toBeInTheDocument(); - }); - - it('heads the no-selection state with the open count', async () => { - store.hasItemsInActiveTab = true; - store.openCount = 4; - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect( - within(getByTestId('workflow-reviews-no-selection')).getByText('4 open reviews'), - ).toBeInTheDocument(); - }); - - it('names the tab in the no-selection state while the count is unknown', async () => { - store.hasItemsInActiveTab = true; - store.openCount = null; - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect( - within(getByTestId('workflow-reviews-no-selection')).getByText('Open reviews'), - ).toBeInTheDocument(); - }); - - it('names the closed tab in the no-selection state while the count is unknown', async () => { - await router.replace('/reviews?state=closed'); - store.hasItemsInActiveTab = true; - store.closedCount = null; - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect( - within(getByTestId('workflow-reviews-no-selection')).getByText('Closed reviews'), - ).toBeInTheDocument(); - }); - - it('heads the no-selection state in the singular for one review', async () => { - store.hasItemsInActiveTab = true; - store.openCount = 1; - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect( - within(getByTestId('workflow-reviews-no-selection')).getByText('1 open review'), - ).toBeInTheDocument(); - }); - - it('heads the no-selection state with the closed count on the closed tab', async () => { - await router.replace('/reviews?state=closed'); - store.hasItemsInActiveTab = true; - store.openCount = 4; - store.closedCount = 2; - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect( - within(getByTestId('workflow-reviews-no-selection')).getByText('2 closed reviews'), - ).toBeInTheDocument(); - }); - - it('shows a retryable error when an initial failure leaves no usable rows', async () => { - store.activeTabInitialLoadFailed = true; - store.isEmpty = false; - store.hasItemsInActiveTab = false; - - const { getByTestId, queryByTestId } = renderComponent(); - await waitAllPromises(); - - const errorState = getByTestId('workflow-reviews-load-error'); - expect(errorState).toHaveTextContent('Could not load workflow reviews'); - expect(queryByTestId('workflow-reviews-no-selection')).not.toBeInTheDocument(); - expect(queryByTestId('workflow-reviews-empty-state')).not.toBeInTheDocument(); - - store.fetchActiveTab.mockClear(); - within(errorState).getByRole('button', { name: 'Retry' }).click(); - await waitAllPromises(); - - expect(store.fetchActiveTab).toHaveBeenCalledTimes(1); - }); - - it('shows the no-selection state when a healthy section has rows', async () => { - store.activeTabInitialLoadFailed = true; - store.hasItemsInActiveTab = true; - store.openCount = 12; - - const { getByTestId, queryByTestId } = renderComponent(); - await waitAllPromises(); - - expect(queryByTestId('workflow-reviews-load-error')).not.toBeInTheDocument(); - expect( - within(getByTestId('workflow-reviews-no-selection')).getByText('12 open reviews'), - ).toBeInTheDocument(); - }); - - it('fetches the route review detail on mount', async () => { - await router.replace('/reviews/req-1'); - - renderComponent(); - await waitAllPromises(); - - expect(store.fetchDetail).toHaveBeenCalledWith('req-1'); - }); - - it('opens a review with its activity already loading', async () => { - await router.replace('/reviews/req-1'); - - renderComponent(); - await waitAllPromises(); - - expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); - }); - - it('swaps in the activity of the next review the viewer picks', async () => { - await router.replace('/reviews/req-1'); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - getByTestId('select-other-review').click(); - await waitAllPromises(); - - expect(activityStore.fetchFeed).toHaveBeenLastCalledWith('req-2'); - }); - - it('selects a review with replace and preserves the query', async () => { - await router.replace('/reviews?state=closed'); - const replaceSpy = vi.spyOn(router, 'replace'); - const pushSpy = vi.spyOn(router, 'push'); - - const { getByTestId } = renderComponent(); - getByTestId('select-review').click(); - await waitAllPromises(); - - expect(replaceSpy).toHaveBeenCalledWith({ - params: { reviewRequestId: 'req-1' }, - query: { state: 'closed' }, - }); - expect(pushSpy).not.toHaveBeenCalled(); - }); - - it('drops the tab when selecting a different review, so it lands on Activity', async () => { - await router.replace('/reviews/req-2?state=closed&tab=changes'); - const replaceSpy = vi.spyOn(router, 'replace'); - - const { getByTestId } = renderComponent(); - getByTestId('select-review').click(); - await waitAllPromises(); - - expect(replaceSpy).toHaveBeenCalledWith({ - params: { reviewRequestId: 'req-1' }, - query: { state: 'closed' }, - }); - }); - - it('keeps the tab when re-selecting the review already open', async () => { - await router.replace('/reviews/req-1?tab=changes'); - const replaceSpy = vi.spyOn(router, 'replace'); - - const { getByTestId } = renderComponent(); - getByTestId('select-review').click(); - await waitAllPromises(); - - expect(replaceSpy).toHaveBeenCalledWith({ - params: { reviewRequestId: 'req-1' }, - query: { tab: 'changes' }, - }); - }); - - it('clears the selection back to the bare inbox path', async () => { - await router.replace('/reviews/req-1?state=closed'); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('clear-review').click(); - await waitAllPromises(); - - expect(router.currentRoute.value.fullPath).toBe('/reviews?state=closed'); - expect(router.currentRoute.value.params.reviewRequestId).toBe(''); - expect(store.clearDetail).toHaveBeenCalled(); - }); - - it('shows the detail skeleton while deep-linked detail is loading', async () => { - await router.replace('/reviews/req-1'); - store.detailLoading = true; - store.detail = null; - - const { container, queryByTestId } = renderComponent(); - await waitAllPromises(); - - expect(container.querySelector('.n8n-loading')).toBeInTheDocument(); - expect(queryByTestId('workflow-reviews-no-selection')).not.toBeInTheDocument(); - }); - - it('renders an inline not-found state without redirecting', async () => { - await router.replace('/reviews/missing'); - store.detailNotFound = true; - const replaceSpy = vi.spyOn(router, 'replace'); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect(getByTestId('workflow-review-detail-not-found')).toBeInTheDocument(); - expect(replaceSpy).not.toHaveBeenCalled(); - expect(router.currentRoute.value.fullPath).toBe('/reviews/missing'); - }); - - it('uses the list item until loaded detail is available', async () => { - await router.replace('/reviews/req-1'); - // Resolved by id across sections, so it works from either one. - store.findItemById.mockReturnValue(createInboxItem()); - - const { getByTestId, queryByTestId } = renderComponent(); - await waitAllPromises(); - expect(getByTestId('workflow-review-request-title')).toHaveTextContent('List review'); - expect( - within(getByTestId('workflow-review-request-title-row')).getByTestId( - 'workflow-review-request-status-dot', - ), - ).toBeInTheDocument(); - // The list item carries no eligibility data, so no decision actions yet - expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument(); - - store.detail = createDetail({ title: 'Detail review' }); - await waitAllPromises(); - expect(getByTestId('workflow-review-request-title')).toHaveTextContent('Detail review'); - expect(getByTestId('workflow-review-decision-popover')).toBeInTheDocument(); - }); - - it('hydrates the tab from the query before loading', async () => { - await router.replace('/reviews?state=closed'); - renderComponent(); - await waitAllPromises(); - - expect(store.activeTab).toBe('closed'); - expect(store.fetchActiveTab).toHaveBeenCalledTimes(1); - }); - - it('uses the open tab for an invalid state query', async () => { - await router.replace('/reviews?state=bogus'); - renderComponent(); - await waitAllPromises(); - - expect(store.activeTab).toBe('open'); - }); - - it('writes tab changes to the query and preserves the selected review', async () => { - await router.replace('/reviews/req-1'); - - const { getByTestId } = renderComponent(); - getByTestId('select-closed-tab').click(); - await waitAllPromises(); - - expect(router.currentRoute.value.query).toEqual({ state: 'closed' }); - expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1'); - - getByTestId('select-open-tab').click(); - await waitAllPromises(); - - expect(router.currentRoute.value.query).toEqual({}); - expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1'); - }); - - it('updates the active tab when navigation changes the state query', async () => { - renderComponent(); - await waitAllPromises(); - store.setActiveTab.mockClear(); - - await router.replace('/reviews?state=closed'); - await waitAllPromises(); - - expect(store.setActiveTab).toHaveBeenCalledWith('closed'); - }); - - describe('sidebar sections', () => { - it('passes both authorship sections on the open tab', async () => { - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect(getByTestId('workflow-review-requests-sidebar').dataset.sections).toBe( - 'waiting,authored', - ); - }); - - it('passes a single flat section on the closed tab', async () => { - await router.replace('/reviews?state=closed'); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect(getByTestId('workflow-review-requests-sidebar').dataset.sections).toBe('closed'); - }); - - it('forwards load-more and retry to the section that asked', async () => { - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('load-more-authored').click(); - getByTestId('retry-waiting').click(); - await waitAllPromises(); - - expect(store.loadMore).toHaveBeenCalledWith('authored'); - expect(store.retry).toHaveBeenCalledWith('waiting'); - }); - - it('retries both sections after an initial tab failure', async () => { - const { getByTestId } = renderComponent(); - await waitAllPromises(); - store.fetchActiveTab.mockClear(); - - getByTestId('retry-active-tab').click(); - await waitAllPromises(); - - expect(store.fetchActiveTab).toHaveBeenCalledTimes(1); - }); - }); - - describe('detail tabs', () => { - beforeEach(async () => { - await router.replace('/reviews/req-1'); - store.detail = createDetail(); - }); - - it('defaults to the activity tab', async () => { - const { getByTestId, queryByTestId } = renderComponent(); - await waitAllPromises(); - - expect(getByTestId('workflow-review-activity-panel')).toBeInTheDocument(); - expect(queryByTestId('workflow-review-changes-panel')).not.toBeInTheDocument(); - }); - - it('hydrates the changes tab from the query', async () => { - await router.replace('/reviews/req-1?tab=changes'); - - const { getByTestId, queryByTestId } = renderComponent(); - await waitAllPromises(); - - expect(getByTestId('workflow-review-changes-panel')).toBeInTheDocument(); - expect(queryByTestId('workflow-review-activity-panel')).not.toBeInTheDocument(); - }); - - it('falls back to the activity tab for an invalid tab query', async () => { - await router.replace('/reviews/req-1?tab=bogus'); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - expect(getByTestId('workflow-review-activity-panel')).toBeInTheDocument(); - }); - - it('writes the tab to the query preserving selection and state', async () => { - await router.replace('/reviews/req-1?state=closed'); - - const { getByRole } = renderComponent(); - await waitAllPromises(); - - getByRole('tab', { name: 'Changes' }).click(); - await waitAllPromises(); - - expect(router.currentRoute.value.query).toEqual({ state: 'closed', tab: 'changes' }); - expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1'); - - getByRole('tab', { name: 'Activity' }).click(); - await waitAllPromises(); - - expect(router.currentRoute.value.query).toEqual({ state: 'closed' }); - expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1'); - }); - }); - - describe('decision actions', () => { - const decisionResponse = ( - overrides: Partial = {}, - ): DecideWorkflowReviewRequestResponse => ({ - id: 'req-1', - state: 'closed', - decision: 'approved', - workflowVersionId: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - ...overrides, - }); - - beforeEach(async () => { - await router.replace('/reviews/req-1'); - store.detail = createDetail(); - store.decideOnReview.mockResolvedValue(decisionResponse()); - }); - - it('offers no decision actions for a closed review', async () => { - store.detail = createDetail({ state: 'closed', decision: 'approved' }); - - const { queryByTestId } = renderComponent(); - await waitAllPromises(); - - expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument(); - }); - - it('submits an approval for the selected review', async () => { - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(store.decideOnReview).toHaveBeenCalledWith('req-1', { decision: 'approved' }); - expect(showError).not.toHaveBeenCalled(); - }); - - it('refetches the detail after a decision on the selected review', async () => { - const { getByTestId } = renderComponent(); - await waitAllPromises(); - store.fetchDetail.mockClear(); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(store.fetchDetail).toHaveBeenCalledWith('req-1'); - }); - - it('submits a change request for the selected review', async () => { - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('request-changes').click(); - await waitAllPromises(); - - expect(store.decideOnReview).toHaveBeenCalledWith('req-1', { - decision: 'changes_requested', - note: 'needs work', - }); - }); - - it('shows a success toast when the approval published the workflow', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ autoPublish: { status: 'published' } }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(showMessage).toHaveBeenCalledWith({ - type: 'success', - title: 'Review approved', - message: 'The reviewed workflow version has been published.', - }); - expect(showError).not.toHaveBeenCalled(); - }); - - it('shows a warning toast with the reason when the auto-publish failed', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(showMessage).toHaveBeenCalledWith({ - type: 'warning', - duration: 0, - title: 'Review approved, but the workflow is not published', - message: 'Version not found. Publish the workflow manually to retry.', - }); - expect(showError).not.toHaveBeenCalled(); - }); - - it('does not double up punctuation on an already-terminated message', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ - autoPublish: { status: 'failed', message: 'Cannot activate an archived workflow.' }, - }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(showMessage).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Cannot activate an archived workflow. Publish the workflow manually to retry.', - }), - ); - }); - - it('shows no publish toast when requesting changes', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ state: 'open', decision: 'changes_requested' }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - store.fetchDetail.mockClear(); - - getByTestId('request-changes').click(); - await waitAllPromises(); - - expect(showMessage).not.toHaveBeenCalled(); - expect(store.fetchDetail).not.toHaveBeenCalled(); - }); - - it('follows a closed review to the closed tab, keeping it selected', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ autoPublish: { status: 'published' } }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1?state=closed'); - expect(router.currentRoute.value.params.reviewRequestId).toBe('req-1'); - }); - - // The review closes on approval whether or not the publish succeeded, and a - // failure is exactly when the card is needed to retry from. - it('follows the review to the closed tab even when the auto-publish failed', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1?state=closed'); - }); - - it('stays on the open tab when the review stays open', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ state: 'open', decision: 'changes_requested' }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('request-changes').click(); - await waitAllPromises(); - - expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1'); - }); - - it('does not renavigate when already on the closed tab', async () => { - await router.replace('/reviews/req-1?state=closed'); - store.activeTab = 'closed'; - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - const replaceSpy = vi.spyOn(router, 'replace'); - - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(replaceSpy).not.toHaveBeenCalled(); - expect(router.currentRoute.value.fullPath).toBe('/reviews/req-1?state=closed'); - }); - - it('shows an error toast when the decision fails', async () => { - const error = new Error('forbidden'); - store.decideOnReview.mockRejectedValueOnce(error); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision'); - }); - - // The detail pane wins over the list item, so a failed decision must refresh it - // too — otherwise the pane stays open and actionable and every retry re-fails. - it('refreshes the detail, both sections and the feed when the decision fails', async () => { - store.decideOnReview.mockRejectedValueOnce(new Error('conflict')); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - store.fetchDetail.mockClear(); - activityStore.fetchFeed.mockClear(); - store.fetchActiveTab.mockClear(); - getByTestId('approve-review').click(); - await waitAllPromises(); - - expect(store.fetchActiveTab).toHaveBeenCalledTimes(1); - expect(store.fetchDetail).toHaveBeenCalledWith('req-1'); - // The other reviewer's decision entry is in the feed, not in the detail payload. - expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); - // The note stays, so the reviewer can retry with it. - expect(activityStore.clearDecisionNote).not.toHaveBeenCalled(); - }); - - it('shows the submitted decision in the feed and drops the note behind it', async () => { - store.decideOnReview.mockResolvedValueOnce( - decisionResponse({ state: 'open', decision: 'changes_requested' }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - activityStore.fetchFeed.mockClear(); - - getByTestId('request-changes').click(); - await waitAllPromises(); - - expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); - // The note it submitted, so a note typed while the decision was in flight survives. - expect(activityStore.clearDecisionNote).toHaveBeenCalledWith('needs work'); - }); - - // Otherwise the late refetch of the decided review wipes the feed of the one the - // viewer is now reading. - it('leaves the feed alone when the decision lands after the viewer moved on', async () => { - let resolveDecision!: () => void; - store.decideOnReview.mockImplementationOnce( - async () => - await new Promise((resolve) => { - resolveDecision = () => - resolve(decisionResponse({ state: 'open', decision: 'changes_requested' })); - }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - getByTestId('approve-review').click(); - await router.replace('/reviews/req-2'); - await waitAllPromises(); - activityStore.fetchFeed.mockClear(); - - resolveDecision(); - await waitAllPromises(); - - expect(activityStore.fetchFeed).not.toHaveBeenCalled(); - expect(activityStore.clearDecisionNote).not.toHaveBeenCalled(); - }); - - // Following the approved review to the closed tab would also switch the feed back, - // throwing away the comment and note the viewer started on the review they moved to. - it('keeps the viewer on the review they moved to when the approval lands', async () => { - let resolveDecision!: () => void; - store.decideOnReview.mockImplementationOnce( - async () => - await new Promise((resolve) => { - resolveDecision = () => resolve(decisionResponse()); - }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - getByTestId('approve-review').click(); - await router.replace('/reviews/req-2'); - await waitAllPromises(); - - resolveDecision(); - await waitAllPromises(); - - expect(router.currentRoute.value.fullPath).toBe('/reviews/req-2'); - }); - - it('shows no publish toast once the viewer has left the page', async () => { - let resolveDecision!: () => void; - store.decideOnReview.mockImplementationOnce( - async () => - await new Promise((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((_resolve, reject) => { - rejectDecision = reject; - }), - ); - - const { getByTestId, unmount } = renderComponent(); - await waitAllPromises(); - store.fetchActiveTab.mockClear(); - getByTestId('approve-review').click(); - unmount(); - - rejectDecision(new Error('conflict')); - await waitAllPromises(); - - expect(showError).not.toHaveBeenCalled(); - expect(store.fetchActiveTab).not.toHaveBeenCalled(); - }); - - // The tab write is reached from a resolved post too, by which time the viewer may be - // on a page where `tab` means something else entirely. - it('leaves the query of another page alone when a comment lands late', async () => { - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - await router.replace('/settings/roles?tab=roles'); - getByTestId('comment-posted').click(); - await waitAllPromises(); - - expect(router.currentRoute.value.fullPath).toBe('/settings/roles?tab=roles'); - }); - - it('locks the decision actions while a decision is in flight', async () => { - let resolveDecision!: () => void; - store.decideOnReview.mockImplementationOnce( - async () => - await new Promise((resolve) => { - resolveDecision = () => resolve(decisionResponse()); - }), - ); - - const { getByTestId } = renderComponent(); - await waitAllPromises(); - - getByTestId('approve-review').click(); - await vi.waitFor(() => { - expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute( - 'data-deciding', - 'true', - ); - }); - - resolveDecision(); - await waitAllPromises(); - - expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute( - 'data-deciding', - 'false', - ); - }); - }); - - it('clears the stores on entry, before loading counts or lists', async () => { - renderComponent(); - - expect(store.reset).toHaveBeenCalledTimes(1); - expect(store.reset.mock.invocationCallOrder[0]).toBeLessThan( - store.fetchSummary.mock.invocationCallOrder[0], - ); - expect(store.reset.mock.invocationCallOrder[0]).toBeLessThan( - store.fetchActiveTab.mock.invocationCallOrder[0], - ); - }); -}); - -function createInboxItem(): WorkflowReviewInboxItem { - return { - id: 'req-1', - projectId: 'proj-1', - title: 'List review', - workflowName: 'My workflow', - workflowVersionId: null, - requester: null, - authors: [], - reviewers: [], - decision: 'pending', - state: 'open', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }; -} - -function createDetail( - overrides: Partial = {}, -): WorkflowReviewRequestDetail { - return { - ...createInboxItem(), - description: null, - workflows: [], - viewerCanDecide: true, - viewerDecisionIneligibilityReason: null, - viewerCanComment: true, - ...overrides, - }; -} diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.vue deleted file mode 100644 index 576cf277d3c..00000000000 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/views/WorkflowReviewRequestsView.vue +++ /dev/null @@ -1,515 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.api.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.api.ts index 24e85f9ba27..f2bd1647627 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.api.ts +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.api.ts @@ -1,33 +1,14 @@ import type { CreateWorkflowReviewRequestDto, - DecideWorkflowReviewRequestDto, - DecideWorkflowReviewRequestResponse, GetWorkflowReviewEligibleReviewersQueryDto, - GetWorkflowReviewInboxSummaryResponse, - ListWorkflowReviewActivityResponse, - ListWorkflowReviewInboxResponse, UpdateWorkflowReviewRequestVersionDto, - WorkflowReviewActivityEntry, WorkflowReviewEligibleReviewersList, - WorkflowReviewInboxCategory, - WorkflowReviewRequestDetail, WorkflowReviewRequestList, WorkflowReviewRequestState, WorkflowReviewRequestSummary, } from '@n8n/api-types'; import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client'; -export type FetchWorkflowReviewInboxParams = { - state?: WorkflowReviewRequestState; - /** Partitions the open tab by authorship */ - category?: WorkflowReviewInboxCategory; - limit?: number; - cursor?: string; -}; - -/** What a reviewer submits with a decision; `pending` is the initial state, never an input. */ -export type WorkflowReviewDecisionInput = Pick; - /** Workflow-scoped list used by the review status sync (toggle + canvas banner). */ export async function fetchWorkflowReviewRequests( context: IRestApiContext, @@ -77,67 +58,3 @@ export async function updateWorkflowReviewRequestVersion( { ...payload }, ); } - -export async function decideWorkflowReviewRequest( - context: IRestApiContext, - workflowReviewRequestId: string, - payload: DecideWorkflowReviewRequestDto, -): Promise { - return await makeRestApiRequest( - context, - 'POST', - `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/decision`, - { ...payload }, - ); -} - -export async function fetchWorkflowReviewInboxSummary( - context: IRestApiContext, -): Promise { - return await makeRestApiRequest(context, 'GET', '/workflow-review-requests/summary'); -} - -/** Cross-project inbox list. */ -export async function fetchWorkflowReviewInbox( - context: IRestApiContext, - params: FetchWorkflowReviewInboxParams, -): Promise { - return await makeRestApiRequest(context, 'GET', '/workflow-review-requests/inbox', params); -} - -export async function fetchWorkflowReviewRequestDetail( - context: IRestApiContext, - workflowReviewRequestId: string, -): Promise { - return await makeRestApiRequest( - context, - 'GET', - `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}`, - ); -} - -export async function fetchWorkflowReviewActivity( - context: IRestApiContext, - workflowReviewRequestId: string, - params: { limit?: number; cursor?: string }, -): Promise { - return await makeRestApiRequest( - context, - 'GET', - `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/activity`, - params, - ); -} - -export async function createWorkflowReviewComment( - context: IRestApiContext, - workflowReviewRequestId: string, - payload: { body: string }, -): Promise { - return await makeRestApiRequest( - context, - 'POST', - `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/comments`, - { ...payload }, - ); -} diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts index 1ecd152989c..344c5be81d2 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts +++ b/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts @@ -1,18 +1 @@ -export function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -type UserNameParts = { - firstName?: string | null; - lastName?: string | null; - email?: string | null; -}; - -export function formatUserDisplayName(user: UserNameParts): string { - return [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email || ''; -} - -/** Names whoever produced an activity entry. The fallback is passed in to keep i18n out of here. */ -export function formatActorName(actor: UserNameParts | null, fallback: string): string { - return actor ? formatUserDisplayName(actor) : fallback; -} +export { formatUserDisplayName } from '@n8n/frontend-module-inbox'; diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.test.ts b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.test.ts new file mode 100644 index 00000000000..82d55e6aaf2 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.test.ts @@ -0,0 +1,108 @@ +import type { WorkflowReviewVersionSnapshot } from '@n8n/api-types'; +import { createComponentRenderer } from '@n8n/frontend-test-utils'; +import { defineComponent, isReactive, reactive } from 'vue'; + +import type { IWorkflowDb } from '@/Interface'; + +import WorkflowDiffSlot from './WorkflowDiffSlot.vue'; + +type DiffProps = { + sourceWorkflow?: IWorkflowDb; + targetWorkflow?: IWorkflowDb; + sourceLabel?: string; + targetLabel?: string; + showFullscreenButton?: boolean; +}; +const { captured } = vi.hoisted(() => ({ captured: [] as DiffProps[] })); +vi.mock('./WorkflowDiffView.vue', () => ({ + default: defineComponent({ + props: [ + 'sourceWorkflow', + 'targetWorkflow', + 'sourceLabel', + 'targetLabel', + 'showFullscreenButton', + ], + setup(props) { + captured.push(props as DiffProps); + }, + template: + '
', + }), +})); +const renderComponent = createComponentRenderer(WorkflowDiffSlot); +function snapshot(): WorkflowReviewVersionSnapshot { + return { + versionId: 'version', + name: null, + createdAt: '2026-01-01T00:00:00.000Z', + nodes: [ + { + id: 'node', + name: 'Node', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + nodeGroups: [], + }; +} +beforeEach(() => { + captured.length = 0; +}); + +it('maps snapshots to detached non-reactive editor workflows', () => { + const target = reactive(snapshot()); + renderComponent({ + props: { + workflowId: 'workflow', + workflowName: 'Workflow', + sourceSnapshot: snapshot(), + targetSnapshot: target, + }, + }); + const { sourceWorkflow, targetWorkflow } = captured[0]; + expect(targetWorkflow).toMatchObject({ + id: 'workflow', + name: 'Workflow', + versionId: 'version', + activeVersionId: null, + }); + expect(isReactive(target)).toBe(true); + expect(isReactive(sourceWorkflow)).toBe(false); + expect(isReactive(targetWorkflow)).toBe(false); + expect(isReactive(targetWorkflow?.nodes)).toBe(false); + expect(targetWorkflow?.nodes).not.toBe(target.nodes); + if (targetWorkflow) targetWorkflow.nodes[0].name = 'Moved in the diff'; + expect(target.nodes[0].name).toBe('Node'); +}); + +it('preserves labels, first-publish text, and the fullscreen control', () => { + const { getByText } = renderComponent({ + props: { + workflowId: 'workflow', + workflowName: 'Workflow', + targetSnapshot: snapshot(), + sourceLabel: 'Published', + targetLabel: 'In review', + showFullscreenButton: true, + }, + slots: { + sourceLabel: 'Previous version', + targetLabel: 'Proposed version', + sourceEmptyText: 'First publication', + }, + }); + expect(captured[0]).toMatchObject({ + sourceWorkflow: undefined, + sourceLabel: 'Published', + targetLabel: 'In review', + showFullscreenButton: true, + }); + expect(getByText('Previous version')).toBeInTheDocument(); + expect(getByText('Proposed version')).toBeInTheDocument(); + expect(getByText('First publication')).toBeInTheDocument(); +}); diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.vue b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.vue new file mode 100644 index 00000000000..54e5f051ab7 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffSlot.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowHistory/utils.ts b/packages/frontend/editor-ui/src/features/workflows/workflowHistory/utils.ts index ef6b8d19344..f0f22379dae 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowHistory/utils.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowHistory/utils.ts @@ -3,7 +3,6 @@ import type { WorkflowPublishHistory, WorkflowHistory, } from '@n8n/rest-api-client/api/workflowHistory'; -import { useI18n } from '@n8n/i18n'; import dateformat from 'dateformat'; export const getLastPublishedVersion = ( @@ -12,27 +11,10 @@ export const getLastPublishedVersion = ( return workflowPublishHistory.findLast((history) => history.event === 'activated'); }; -export const generateVersionLabelFromId = (versionId: string) => { - return `Version ${versionId.substring(0, 8)}`; -}; - -export const getVersionLabel = ({ - workflowHistory, - currentVersionId, -}: { - workflowHistory: Pick; - currentVersionId?: string; -}) => { - const i18n = useI18n(); - if (workflowHistory.name) { - return workflowHistory.name; - } - - const isCurrentVersion = workflowHistory.versionId === currentVersionId; - return isCurrentVersion - ? i18n.baseText('workflowHistory.item.currentChanges') - : generateVersionLabelFromId(workflowHistory.versionId); -}; +export { + generateVersionLabelFromId, + getVersionLabel, +} from '@n8n/composables/useWorkflowVersionLabel'; export const formatTimestamp = (value: string) => { const currentYear = new Date().getFullYear().toString(); diff --git a/packages/frontend/editor-ui/tsconfig.json b/packages/frontend/editor-ui/tsconfig.json index 322a417d441..a0d7ba97d49 100644 --- a/packages/frontend/editor-ui/tsconfig.json +++ b/packages/frontend/editor-ui/tsconfig.json @@ -25,6 +25,8 @@ "@n8n/composables*": ["../@n8n/composables/src*"], "@n8n/constants*": ["../../@n8n/constants/src*"], "@n8n/frontend-module-sdk": ["../@n8n/frontend-module-sdk/src/index.ts"], + "@n8n/frontend-module-inbox": ["../../modules/inbox/frontend/src/index.ts"], + "@n8n/frontend-module-inbox/*": ["../../modules/inbox/frontend/src/*"], "@n8n/frontend-module-mcp": ["../../modules/mcp/frontend/src/index.ts"], "@n8n/frontend-module-mcp/*": ["../../modules/mcp/frontend/src/*"], "@n8n/frontend-module-type-availability-policies": [ diff --git a/packages/modules/inbox/frontend/README.md b/packages/modules/inbox/frontend/README.md new file mode 100644 index 00000000000..b4f5dadbcc1 --- /dev/null +++ b/packages/modules/inbox/frontend/README.md @@ -0,0 +1,60 @@ +# @n8n/frontend-module-inbox + +The shared Inbox lists workflow reviews. Review details, comments, and decisions live in `src/reviews`. The editor supplies only the workflow diff renderer through the SDK component registry. Backend services keep their existing access rules and actions. + +Open has two groups: Waiting for review and Authored by you. Each group has its +own cursor, Load more button, and retry. +Closed is a flat list with infinite scroll. Group collapse state persists per user. + +Review decisions update the loaded rows and counts in place. Approving a selected +review opens the Closed tab, as before. + +Frontend feature module. Consumed from source by the editor-ui shell through +`src/app/modules.manifest.ts`; there is no build step and no `dist`. + +Read the full guide at `packages/@n8n/module-cli/frontend-module-guide.md`. It gives the +descriptor contract, the registration points and the known problems. This README gives a +summary only. + +```bash +pnpm turbo typecheck --filter=@n8n/frontend-module-inbox +pnpm turbo lint --filter=@n8n/frontend-module-inbox +pnpm turbo lint:styles --filter=@n8n/frontend-module-inbox +pnpm turbo test --filter=@n8n/frontend-module-inbox +``` + +Go through turbo, not `pnpm --filter typecheck`: this package is consumed +from source, and on a cold tree its platform dependencies have not been built +yet. Turbo builds them first; the bare pnpm form does not. + +## Import rules + +- Depend on foundation and platform packages only (`@n8n/design-system`, + `@n8n/stores`, `@n8n/composables`, `@n8n/i18n`, `@n8n/rest-api-client`, + `@n8n/api-types`, `@n8n/permissions`, `@n8n/frontend-constants`, `n8n-workflow`, + `@n8n/frontend-module-sdk`). Never import another + `@n8n/frontend-module-*`, and never import `@/…` from the shell. +- `@n8n/stores` and `@n8n/composables` are **subpath-only** — import + `@n8n/stores/settings.store`, not `@n8n/stores`. +- The no-cross-module rule is currently a convention: the shared tsconfig base + omits sibling modules from `paths`, which blocks an accidental import but not + a deliberate one (declaring the dependency makes it typecheck clean). The + ESLint rule that actually enforces it is CAT-3692. + +## Adding UI + +`vite.config.ts` already carries the three plugins a design-system consumer +needs, so a `.vue` file compiles in tests without further setup: + +- `@vitejs/plugin-vue` — single-file components. +- `unplugin-icons` — the `~icons/lucide/*` virtual modules behind `N8nIcon`. +- `vite-svg-loader` — design-system's `custom/*.svg` icon components. Without + it an `.svg` import is a data-URI string, which Vue renders as a tag name. + +`src/__tests__/design-system-icons.test.ts` renders one icon of each kind, so a +plugin dropped from `vite.config.ts` fails this package's own suite. Keep that +test while the module renders any design-system component. + +SCSS goes in a scoped ` diff --git a/packages/modules/inbox/frontend/src/components/InboxList.test.ts b/packages/modules/inbox/frontend/src/components/InboxList.test.ts new file mode 100644 index 00000000000..3ce43b391d1 --- /dev/null +++ b/packages/modules/inbox/frontend/src/components/InboxList.test.ts @@ -0,0 +1,229 @@ +import type { InboxWorkflowReviewItem } from '@n8n/api-types'; +import { createComponentRenderer } from '@n8n/frontend-test-utils'; +import { useUsersStore } from '@n8n/stores/users.store'; +import { fireEvent, within } from '@testing-library/vue'; +import { useIntersectionObserver } from '@vueuse/core'; + +import InboxList from './InboxList.vue'; +import type { InboxListSection } from './InboxListSection.vue'; + +vi.mock('@vueuse/core', async (importOriginal) => ({ + ...(await importOriginal()), + useIntersectionObserver: vi.fn(), +})); + +function section( + key: InboxListSection['key'], + props: Partial = {}, +): InboxListSection { + return { + key, + items: [], + loading: false, + loadingMore: false, + hasMore: false, + error: null, + failedRequest: null, + partial: false, + ...props, + }; +} +function review(): InboxWorkflowReviewItem { + return { + type: 'workflow_review', + id: 'review', + state: 'open', + decision: 'pending', + projectId: 'project', + title: 'Review my workflow', + workflowName: 'My workflow', + workflowVersionId: null, + requester: null, + authors: [], + reviewers: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; +} +const renderComponent = createComponentRenderer(InboxList, { + props: { + sections: [section('waiting'), section('authored')], + activeTab: 'open', + selectedKey: null, + openCount: null, + closedCount: null, + }, +}); +beforeEach(() => localStorage.clear()); +function intersect(isIntersecting = true) { + const onIntersect = vi.mocked(useIntersectionObserver).mock.calls.at(-1)![1]; + onIntersect([{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver); +} + +it('shows one initial skeleton without empty group headers', () => { + const { getAllByTestId, queryByTestId } = renderComponent({ + props: { + sections: [section('waiting', { loading: true }), section('authored', { loading: true })], + }, + }); + expect(getAllByTestId('inbox-list-skeleton')).toHaveLength(1); + expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument(); +}); +it('offers one retry when the first load fails without rows', () => { + const { getAllByRole, queryByTestId, emitted } = renderComponent({ + props: { + sections: [ + section('waiting', { error: new Error('Request failed'), failedRequest: 'list' }), + section('authored', { error: new Error('Request failed'), failedRequest: 'list' }), + ], + }, + }); + const buttons = getAllByRole('button', { name: 'Retry' }); + expect(buttons).toHaveLength(1); + buttons[0].click(); + expect(emitted('retryActiveTab')).toEqual([[]]); + expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument(); +}); +it('retries the failed page after all loaded rows leave the list', () => { + const { getByRole, emitted } = renderComponent({ + props: { + sections: [ + section('waiting', { + hasMore: true, + error: new Error('Page failed'), + failedRequest: 'loadMore', + }), + section('authored'), + ], + }, + }); + getByRole('button', { name: 'Retry' }).click(); + expect(emitted('retry')).toEqual([['waiting']]); + expect(emitted('retryActiveTab')).toBeUndefined(); + expect(getByRole('button', { name: 'Waiting for your review' })).toBeInTheDocument(); +}); +it('keeps healthy rows visible beside a failed group', () => { + const { queryByRole, getByRole, emitted } = renderComponent({ + props: { + sections: [ + section('waiting', { items: [review()] }), + section('authored', { error: new Error('Request failed') }), + ], + }, + }); + expect(getByRole('option')).toHaveTextContent('Review my workflow'); + getByRole('button', { name: 'Retry' }).click(); + expect(emitted('retry')).toEqual([['authored']]); + expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); +}); +it('hides settled empty group headers', () => { + const { queryByTestId } = renderComponent(); + expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument(); +}); +it('keeps a partial empty group visible with its own retry', () => { + const { getByRole, emitted } = renderComponent({ + props: { sections: [section('waiting', { partial: true }), section('authored')] }, + }); + expect(getByRole('button', { name: 'Waiting for your review' })).toBeInTheDocument(); + getByRole('button', { name: 'Retry' }).click(); + expect(emitted('refreshSection')).toEqual([['waiting']]); +}); +it('collapses groups without requesting more data', async () => { + const { getByRole, queryByRole, emitted } = renderComponent({ + props: { sections: [section('waiting', { items: [review()] })] }, + }); + const header = getByRole('button', { name: 'Waiting for your review' }); + await fireEvent.click(header); + expect(header).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('option')).not.toBeInTheDocument(); + await fireEvent.click(header); + expect(getByRole('option')).toHaveTextContent('Review my workflow'); + expect(emitted('loadMore')).toBeUndefined(); +}); +it('uses the impersonal waiting label for administrators', () => { + vi.spyOn(useUsersStore(), 'isAdminOrOwner', 'get').mockReturnValue(true); + const { getByRole } = renderComponent({ + props: { sections: [section('waiting', { items: [review()] })] }, + }); + expect(getByRole('button', { name: 'Waiting for review' })).toBeInTheDocument(); +}); +it('keeps loading open groups explicit and identifies the group', () => { + const { emitted, getByRole } = renderComponent({ + props: { sections: [section('authored', { hasMore: true })] }, + }); + intersect(); + expect(emitted('loadMore')).toBeUndefined(); + getByRole('button', { name: 'Load more' }).click(); + expect(emitted('loadMore')).toEqual([['authored']]); +}); +it('loads Closed at the bottom without group headers or a Load more button', () => { + const { emitted, queryByRole, queryByTestId } = renderComponent({ + props: { activeTab: 'closed', sections: [section('closed', { hasMore: true })] }, + }); + intersect(false); + expect(emitted('loadMore')).toBeUndefined(); + intersect(); + expect(emitted('loadMore')).toEqual([['closed']]); + expect(queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + expect(queryByTestId('inbox-section-header')).not.toBeInTheDocument(); +}); +it.each([{ loading: true }, { loadingMore: true }, { hasMore: false }])( + 'does not automatically load Closed with %o', + (props) => { + const { emitted } = renderComponent({ + props: { activeTab: 'closed', sections: [section('closed', { hasMore: true, ...props })] }, + }); + intersect(); + expect(emitted('loadMore')).toBeUndefined(); + }, +); +it('waits for Retry after a failed Closed page and removes the sentinel on Open', async () => { + const closed = section('closed', { hasMore: true, items: [{ ...review(), state: 'closed' }] }); + const { rerender, emitted } = renderComponent({ + props: { activeTab: 'closed', sections: [closed] }, + }); + const target = vi.mocked(useIntersectionObserver).mock.calls.at(-1)![0]; + expect(target).not.toHaveProperty('value', null); + intersect(); + await rerender({ sections: [{ ...closed, loadingMore: true }] }); + expect(target).toHaveProperty('value', null); + intersect(); + expect(emitted('loadMore')).toHaveLength(1); + await rerender({ + sections: [{ ...closed, error: new Error('Page failed') }], + }); + expect(target).toHaveProperty('value', null); + intersect(); + expect(emitted('loadMore')).toHaveLength(1); + await rerender({ sections: [closed] }); + expect(target).not.toHaveProperty('value', null); + intersect(); + expect(emitted('loadMore')).toHaveLength(2); + await rerender({ activeTab: 'open', sections: [section('waiting')] }); + expect(target).toHaveProperty('value', null); +}); +it('separates partial-source recovery from a failed page retry', () => { + const { getByTestId, emitted } = renderComponent({ + props: { sections: [section('waiting', { partial: true, error: new Error('Page failed') })] }, + }); + within(getByTestId('inbox-partial')).getByRole('button', { name: 'Retry' }).click(); + expect(emitted('refreshSection')).toEqual([['waiting']]); + expect(emitted('retry')).toBeUndefined(); + within(getByTestId('inbox-list-error')).getByRole('button', { name: 'Retry' }).click(); + expect(emitted('retry')).toEqual([['waiting']]); +}); + +it('keeps review selection and keyboard activation across the extracted row', async () => { + const item = review(); + const { getByRole, emitted } = renderComponent({ + props: { + selectedKey: `${item.type}:${item.id}`, + sections: [section('waiting', { items: [item] })], + }, + }); + const row = getByRole('option', { selected: true }); + expect(row).toHaveTextContent(item.title); + await fireEvent.keyDown(row, { key: 'Enter' }); + await fireEvent.keyDown(row, { key: ' ' }); + expect(emitted('select')).toEqual([[item], [item]]); +}); diff --git a/packages/modules/inbox/frontend/src/components/InboxList.vue b/packages/modules/inbox/frontend/src/components/InboxList.vue new file mode 100644 index 00000000000..d80a30f65de --- /dev/null +++ b/packages/modules/inbox/frontend/src/components/InboxList.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/packages/modules/inbox/frontend/src/components/InboxListItem.vue b/packages/modules/inbox/frontend/src/components/InboxListItem.vue new file mode 100644 index 00000000000..73638cb9a22 --- /dev/null +++ b/packages/modules/inbox/frontend/src/components/InboxListItem.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/packages/modules/inbox/frontend/src/components/InboxListSection.vue b/packages/modules/inbox/frontend/src/components/InboxListSection.vue new file mode 100644 index 00000000000..80e3c84714e --- /dev/null +++ b/packages/modules/inbox/frontend/src/components/InboxListSection.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/composables/useReviewInboxSectionCollapse.test.ts b/packages/modules/inbox/frontend/src/composables/useReviewInboxSectionCollapse.test.ts similarity index 67% rename from packages/frontend/editor-ui/src/features/workflow-reviews/composables/useReviewInboxSectionCollapse.test.ts rename to packages/modules/inbox/frontend/src/composables/useReviewInboxSectionCollapse.test.ts index 20e9b531a25..29128859d38 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/composables/useReviewInboxSectionCollapse.test.ts +++ b/packages/modules/inbox/frontend/src/composables/useReviewInboxSectionCollapse.test.ts @@ -1,7 +1,6 @@ import { createPinia, setActivePinia } from 'pinia'; import { nextTick } from 'vue'; -import { LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS } from '@/app/constants/localStorage'; import { useUsersStore } from '@n8n/stores/users.store'; import { useReviewInboxSectionCollapse } from './useReviewInboxSectionCollapse'; @@ -17,9 +16,18 @@ describe('useReviewInboxSectionCollapse', () => { expect(isCollapsed('waiting')).toBe(false); expect(isCollapsed('authored')).toBe(false); - expect( - localStorage.getItem(LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS('user-1')), - ).toBeNull(); + expect(localStorage.getItem('user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBeNull(); + }); + + it('restores the existing review section preference', () => { + useUsersStore().currentUserId = 'user-1'; + localStorage.setItem( + 'user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS', + JSON.stringify({ waiting: true, authored: false }), + ); + const { isCollapsed } = useReviewInboxSectionCollapse(); + expect(isCollapsed('waiting')).toBe(true); + expect(isCollapsed('authored')).toBe(false); }); it('persists a collapsed section for the current user', async () => { @@ -31,9 +39,9 @@ describe('useReviewInboxSectionCollapse', () => { expect(isCollapsed('authored')).toBe(true); expect(isCollapsed('waiting')).toBe(false); - expect( - localStorage.getItem(LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS('user-1')), - ).toBe(JSON.stringify({ waiting: false, authored: true })); + expect(localStorage.getItem('user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBe( + JSON.stringify({ waiting: false, authored: true }), + ); }); it('keeps the choice independent between users', () => { @@ -53,8 +61,6 @@ describe('useReviewInboxSectionCollapse', () => { await nextTick(); expect(isCollapsed('waiting')).toBe(true); - expect( - localStorage.getItem(LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS('')), - ).toBeNull(); + expect(localStorage.getItem('_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBeNull(); }); }); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/composables/useReviewInboxSectionCollapse.ts b/packages/modules/inbox/frontend/src/composables/useReviewInboxSectionCollapse.ts similarity index 88% rename from packages/frontend/editor-ui/src/features/workflow-reviews/composables/useReviewInboxSectionCollapse.ts rename to packages/modules/inbox/frontend/src/composables/useReviewInboxSectionCollapse.ts index 06b4e051948..627d21bfdba 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/composables/useReviewInboxSectionCollapse.ts +++ b/packages/modules/inbox/frontend/src/composables/useReviewInboxSectionCollapse.ts @@ -1,7 +1,6 @@ import { useLocalStorage } from '@vueuse/core'; import { ref } from 'vue'; -import { LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS } from '@/app/constants/localStorage'; import { useUsersStore } from '@n8n/stores/users.store'; /** Only the open tab is sectioned, so only those two sections can collapse. */ @@ -22,7 +21,7 @@ export const useReviewInboxSectionCollapse = () => { const collapsedSections = userId ? useLocalStorage( - LOCAL_STORAGE_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS(userId), + `${userId}_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS`, { ...DEFAULT_COLLAPSED }, { writeDefaults: false }, ) diff --git a/packages/modules/inbox/frontend/src/inbox.api.ts b/packages/modules/inbox/frontend/src/inbox.api.ts new file mode 100644 index 00000000000..7fd18588099 --- /dev/null +++ b/packages/modules/inbox/frontend/src/inbox.api.ts @@ -0,0 +1,20 @@ +import type { + GetInboxSummaryResponse, + InboxCategory, + InboxState, + ListInboxResponse, +} from '@n8n/api-types'; +import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client'; + +export async function fetchInbox( + context: IRestApiContext, + query: { state: InboxState; category?: InboxCategory; limit: number; cursor?: string }, +): Promise { + return await makeRestApiRequest(context, 'GET', '/inbox', query); +} + +export async function fetchInboxSummary( + context: IRestApiContext, +): Promise { + return await makeRestApiRequest(context, 'GET', '/inbox/summary'); +} diff --git a/packages/modules/inbox/frontend/src/inbox.constants.ts b/packages/modules/inbox/frontend/src/inbox.constants.ts new file mode 100644 index 00000000000..45550a06604 --- /dev/null +++ b/packages/modules/inbox/frontend/src/inbox.constants.ts @@ -0,0 +1,16 @@ +import type { InboxSourceType, InboxState, WorkflowReviewRequestDecision } from '@n8n/api-types'; + +export const INBOX_VIEW = 'Inbox'; +export const INBOX_PAGE_LIMIT = 15; + +export type InboxSectionKey = 'waiting' | 'authored' | 'closed'; + +export type InboxSelection = { type: 'workflow_review'; id: string }; + +export type InboxItemChange = { + type: InboxSourceType; + id: string; + state?: InboxState; + decision?: WorkflowReviewRequestDecision; + updatedAt?: string; +}; diff --git a/packages/modules/inbox/frontend/src/inbox.module.ts b/packages/modules/inbox/frontend/src/inbox.module.ts new file mode 100644 index 00000000000..a3968592278 --- /dev/null +++ b/packages/modules/inbox/frontend/src/inbox.module.ts @@ -0,0 +1,50 @@ +import { defineFrontendModule } from '@n8n/frontend-module-sdk'; +import type { RouteRecordSingleView } from 'vue-router'; + +const inboxView = { + component: async () => await import('./views/InboxView.vue'), + + async beforeEnter() { + const { useSettingsStore } = await import('@n8n/stores/settings.store'); + + return useSettingsStore().settings.inbox?.enabled === true || '/'; + }, + + meta: { layout: 'default', middleware: ['authenticated', 'custom'] }, +} satisfies Pick; + +export const InboxModule = defineFrontendModule({ + id: 'inbox', + name: 'Inbox', + description: 'The shared Inbox for workflow reviews.', + icon: 'inbox', + routes: [ + // Keep legacy /reviews links working after the move to Inbox. + { + path: '/reviews/:reviewRequestId?', + redirect: (to) => { + const reviewId = to.params.reviewRequestId; + + if (typeof reviewId === 'string' && reviewId) { + return { + name: 'WorkflowReviewRequestsView', + params: { reviewId }, + query: to.query, + }; + } + + return { name: 'Inbox', query: to.query }; + }, + }, + { + ...inboxView, + path: '/inbox', + name: 'Inbox', + }, + { + ...inboxView, + path: '/inbox/reviews/:reviewId', + name: 'WorkflowReviewRequestsView', + }, + ], +}); diff --git a/packages/modules/inbox/frontend/src/inbox.routes.test.ts b/packages/modules/inbox/frontend/src/inbox.routes.test.ts new file mode 100644 index 00000000000..7d24df40370 --- /dev/null +++ b/packages/modules/inbox/frontend/src/inbox.routes.test.ts @@ -0,0 +1,111 @@ +import type { InboxWorkflowReviewItem } from '@n8n/api-types'; +import { createMemoryHistory, createRouter } from 'vue-router'; + +import { InboxModule } from './inbox.module'; +import { inboxItemLocation, isInboxRoute, selectionFromRoute } from './inbox.routes'; + +const settingsStore = vi.hoisted(() => ({ settings: { inbox: { enabled: true } } })); +vi.mock('@n8n/stores/settings.store', () => ({ useSettingsStore: () => settingsStore })); + +beforeEach(() => { + settingsStore.settings.inbox.enabled = true; +}); + +function createInboxRouter() { + const routes = (InboxModule.routes ?? []).map((route) => + 'component' in route ? { ...route, component: { template: '
' } } : route, + ); + return createRouter({ history: createMemoryHistory(), routes }); +} + +const review = { + type: 'workflow_review', + id: 'review-1', + state: 'open', + projectId: 'project', + title: 'Review', + workflowName: 'Workflow', + workflowVersionId: null, + requester: null, + authors: [], + reviewers: [], + decision: 'pending', + createdAt: '', + updatedAt: '', +} satisfies InboxWorkflowReviewItem; + +it('redirects to home when Inbox is disabled', async () => { + settingsStore.settings.inbox.enabled = false; + const router = createInboxRouter(); + router.addRoute({ path: '/', name: 'home', component: { template: '
' } }); + await router.push('/inbox'); + expect(router.currentRoute.value).toMatchObject({ path: '/', name: 'home' }); +}); + +it('redirects an old review link and preserves filters and the detail tab', async () => { + const router = createInboxRouter(); + await router.push('/reviews/review-1?state=closed&tab=changes&filter=a&filter=b'); + expect(router.currentRoute.value.path).toBe('/inbox/reviews/review-1'); + expect(router.currentRoute.value.query).toEqual({ + state: 'closed', + tab: 'changes', + filter: ['a', 'b'], + }); +}); + +it('redirects the old review list without selecting an item', async () => { + const router = createInboxRouter(); + await router.push('/reviews?state=closed'); + expect(router.currentRoute.value.fullPath).toBe('/inbox?state=closed'); + expect(selectionFromRoute(router.currentRoute.value)).toBeNull(); +}); + +it('selects a review when opening its detail route directly', async () => { + const router = createInboxRouter(); + await router.push('/inbox/reviews/review-1'); + expect(selectionFromRoute(router.currentRoute.value)).toEqual({ + type: 'workflow_review', + id: 'review-1', + }); +}); + +it.each([ + ['Inbox', true], + ['WorkflowReviewRequestsView', true], + ['Workflow', false], + [undefined, false], +])('recognizes the Inbox route %s: %s', (name, expected) => { + expect(isInboxRoute({ name })).toBe(expected); +}); + +it('keeps the tab only when selecting the same item', async () => { + const router = createInboxRouter(); + await router.push('/inbox/reviews/review-1?state=closed&tab=changes'); + expect(router.resolve(inboxItemLocation(review, router.currentRoute.value))).toMatchObject({ + path: '/inbox/reviews/review-1', + query: { state: 'closed', tab: 'changes' }, + }); + expect( + router.resolve(inboxItemLocation({ ...review, id: 'review-2' }, router.currentRoute.value)) + .query, + ).toEqual({ state: 'closed' }); +}); + +it('restores selection and tabs when navigating back and forward', async () => { + const router = createInboxRouter(); + await router.push('/inbox'); + await router.push('/inbox/reviews/review-1?tab=changes'); + await router.push(inboxItemLocation({ ...review, id: 'review-2' }, router.currentRoute.value)); + router.back(); + await vi.waitFor(() => + expect(router.currentRoute.value.fullPath).toBe('/inbox/reviews/review-1?tab=changes'), + ); + expect(selectionFromRoute(router.currentRoute.value)).toEqual({ + type: 'workflow_review', + id: 'review-1', + }); + router.forward(); + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/inbox/reviews/review-2')); + expect(selectionFromRoute(router.currentRoute.value)?.id).toBe('review-2'); + expect(router.currentRoute.value.query.tab).toBeUndefined(); +}); diff --git a/packages/modules/inbox/frontend/src/inbox.routes.ts b/packages/modules/inbox/frontend/src/inbox.routes.ts new file mode 100644 index 00000000000..d35fa9f9437 --- /dev/null +++ b/packages/modules/inbox/frontend/src/inbox.routes.ts @@ -0,0 +1,30 @@ +import type { InboxItem } from '@n8n/api-types'; +import type { LocationQuery, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router'; + +import { INBOX_VIEW, type InboxSelection } from './inbox.constants'; +import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './reviews/constants'; + +type InboxRoute = Pick; + +function stringParam(value: LocationQuery[string]): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function isInboxRoute(route: Pick): boolean { + return route.name === INBOX_VIEW || route.name === WORKFLOW_REVIEW_REQUESTS_VIEW; +} + +export function selectionFromRoute({ name, params }: InboxRoute): InboxSelection | null { + if (name === WORKFLOW_REVIEW_REQUESTS_VIEW) { + const id = stringParam(params.reviewId); + return id ? { type: 'workflow_review', id } : null; + } + return null; +} + +export function inboxItemLocation(item: InboxItem, route?: InboxRoute): RouteLocationRaw { + const next: LocationQuery = { ...route?.query }; + const selected = route ? selectionFromRoute(route) : null; + if (selected?.type !== item.type || selected.id !== item.id) delete next.tab; + return { name: WORKFLOW_REVIEW_REQUESTS_VIEW, params: { reviewId: item.id }, query: next }; +} diff --git a/packages/modules/inbox/frontend/src/inbox.store.test.ts b/packages/modules/inbox/frontend/src/inbox.store.test.ts new file mode 100644 index 00000000000..6fdd5e0c4fb --- /dev/null +++ b/packages/modules/inbox/frontend/src/inbox.store.test.ts @@ -0,0 +1,349 @@ +import type { InboxItem, ListInboxResponse } from '@n8n/api-types'; +import { ResponseError } from '@n8n/rest-api-client'; +import { useSettingsStore } from '@n8n/stores/settings.store'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; + +import * as api from './inbox.api'; +import { createInboxListSlice, useInboxStore } from './inbox.store'; + +vi.mock('./inbox.api'); + +const waitingOrClosedRequest = vi.fn(); +const authoredRequest = vi.fn(); + +function page( + data: InboxItem[] = [], + overrides: Partial = {}, +): ListInboxResponse { + return { + data, + nextCursor: null, + hasMore: false, + partial: false, + failedSources: [], + disabledSources: [], + ...overrides, + }; +} +function enableInbox() { + useSettingsStore().settings.inbox = { + enabled: true, + availableTypes: ['workflow_review'], + failedTypes: [], + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + enableInbox(); + vi.mocked(api.fetchInboxSummary).mockResolvedValue({ + counts: { open: 2, closed: 1 }, + partial: false, + failedSources: [], + disabledSources: [], + }); + waitingOrClosedRequest.mockResolvedValue(page()); + authoredRequest.mockResolvedValue(page()); + vi.mocked(api.fetchInbox).mockImplementation(async (context, query) => + query.category === 'authored' + ? await authoredRequest(context, query) + : await waitingOrClosedRequest(context, query), + ); +}); + +it('keeps known counts when a later summary request fails', async () => { + const store = useInboxStore(); + await store.fetchSummary(); + vi.mocked(api.fetchInboxSummary).mockRejectedValueOnce(new Error('Summary unavailable')); + await store.fetchSummary(); + expect(store.openCount).toBe(2); + expect(store.closedCount).toBe(1); +}); + +describe('Inbox list requests', () => { + it('restarts from the first page and exposes a failed reload', async () => { + const pending = createDeferredPromise(); + const request = vi + .fn() + .mockResolvedValueOnce(page([reviewItem('first')])) + .mockReturnValueOnce(pending.promise); + const slice = createInboxListSlice(request, vi.fn()); + await slice.fetchList(); + const refresh = slice.fetchList(); + expect(slice.items).toEqual([]); + pending.reject(new Error('temporarily unavailable')); + await refresh; + expect(slice.items).toEqual([]); + expect(slice.error).toBeInstanceOf(Error); + }); + + it('ignores an old load-more response after refreshing from the first page', async () => { + const pending = createDeferredPromise(); + const request = vi + .fn() + .mockResolvedValueOnce(page([reviewItem('first')], { hasMore: true, nextCursor: 'page-2' })) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(page([reviewItem('newest')])); + const slice = createInboxListSlice(request, vi.fn()); + await slice.fetchList(); + const loadMore = slice.loadMore(); + await slice.fetchList(); + pending.resolve(page([reviewItem('older')])); + await loadMore; + expect(slice.items.map((item) => item.id)).toEqual(['newest']); + expect(slice.loadingMore).toBe(false); + }); + + it('continues a partial cursor chain and retries a failed page without losing its rows', async () => { + const partial = { partial: true, failedSources: ['workflow_review' as const] }; + const request = vi + .fn() + .mockResolvedValueOnce( + page([reviewItem('first')], { ...partial, hasMore: true, nextCursor: 'partial-page' }), + ) + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce(page([reviewItem('older')], partial)); + const slice = createInboxListSlice(request, vi.fn()); + await slice.fetchList(); + await slice.loadMore(); + expect(slice.nextCursor).toBe('partial-page'); + expect(slice.items).toHaveLength(1); + await slice.retry(); + expect(request).toHaveBeenLastCalledWith('partial-page'); + expect(slice.items).toHaveLength(2); + expect(slice.partial).toBe(true); + }); +}); + +describe('shared Inbox state', () => { + it('keeps requests for Open and Closed independent', async () => { + const open = createDeferredPromise(); + waitingOrClosedRequest.mockImplementation(async (_context, query) => + query.state === 'open' ? await open.promise : page([reviewItem('closed')]), + ); + const store = useInboxStore(); + const request = store.refreshListAndSummary(); + await store.setActiveTab('closed'); + open.resolve(page([reviewItem('open')])); + await request; + expect(store.activeTab).toBe('closed'); + expect(store.lists.closed.items[0].id).toBe('closed'); + expect(store.lists.waiting.items[0].id).toBe('open'); + }); + + it('treats a failed count as unknown while the list remains usable', async () => { + waitingOrClosedRequest.mockResolvedValue(page([reviewItem('first')])); + vi.mocked(api.fetchInboxSummary).mockResolvedValue({ + counts: null, + partial: true, + failedSources: ['workflow_review'], + disabledSources: [], + }); + const store = useInboxStore(); + await store.refreshListAndSummary(); + expect(store.lists.waiting.items).toHaveLength(1); + expect(store.openCount).toBeNull(); + }); + + it('clears disabled-source rows from both tabs even when all reads fail', async () => { + const store = useInboxStore(); + store.lists.waiting.items = [reviewItem('open')]; + store.lists.closed.items = [reviewItem('closed')]; + waitingOrClosedRequest.mockRejectedValue( + new ResponseError('unavailable', { + httpStatusCode: 503, + meta: { disabledSources: ['workflow_review'], failedSources: [] }, + }), + ); + await store.refreshListAndSummary(); + expect(store.lists.waiting.items).toEqual([]); + expect(store.lists.closed.items).toEqual([]); + expect(store.disabledSources).toEqual(['workflow_review']); + }); +}); + +it('allows a source to recover on a fresh page without a settings reload', async () => { + const store = useInboxStore(); + waitingOrClosedRequest + .mockResolvedValueOnce(page([], { disabledSources: ['workflow_review'] })) + .mockResolvedValueOnce(page([reviewItem('recovered')])); + await store.refreshListAndSummary(); + expect(store.disabledSources).toContain('workflow_review'); + await store.refreshListAndSummary(); + expect(store.disabledSources).not.toContain('workflow_review'); + expect(store.lists.waiting.items[0].id).toBe('recovered'); +}); + +it('starts a new cursor chain to recover a failed source after paging', async () => { + const partial = { partial: true, failedSources: ['workflow_review' as const] }; + const request = vi + .fn() + .mockResolvedValueOnce( + page([reviewItem('first')], { ...partial, hasMore: true, nextCursor: 'partial-second' }), + ) + .mockResolvedValueOnce( + page([reviewItem('older')], { ...partial, hasMore: true, nextCursor: 'partial-third' }), + ) + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce( + page([reviewItem('recovered')], { hasMore: true, nextCursor: 'complete-second' }), + ); + const slice = createInboxListSlice(request, vi.fn()); + await slice.fetchList(); + await slice.loadMore(); + await slice.fetchList(); + expect(slice.items).toEqual([]); + expect(slice.nextCursor).toBeNull(); + await slice.retry(); + expect(request).toHaveBeenLastCalledWith(); + expect(slice.items.map((item) => item.id)).toEqual(['recovered']); + expect(slice.nextCursor).toBe('complete-second'); + expect(slice.partial).toBe(false); +}); + +it('requests the two Open categories and omits category for Closed', async () => { + const store = useInboxStore(); + await store.refreshListAndSummary(); + expect(api.fetchInbox).toHaveBeenCalledWith(expect.anything(), { + state: 'open', + category: 'waiting', + limit: 15, + cursor: undefined, + }); + expect(api.fetchInbox).toHaveBeenCalledWith(expect.anything(), { + state: 'open', + category: 'authored', + limit: 15, + cursor: undefined, + }); + await store.setActiveTab('closed'); + expect(api.fetchInbox).toHaveBeenLastCalledWith(expect.anything(), { + state: 'closed', + category: undefined, + limit: 15, + cursor: undefined, + }); +}); + +it('keeps each Open group cursor and retry independent', async () => { + waitingOrClosedRequest.mockResolvedValueOnce( + page([reviewItem('waiting')], { hasMore: true, nextCursor: 'waiting-2' }), + ); + authoredRequest + .mockResolvedValueOnce( + page([reviewItem('authored')], { hasMore: true, nextCursor: 'authored-2' }), + ) + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce(page([reviewItem('older')])); + const store = useInboxStore(); + await store.refreshListAndSummary(); + await store.lists.authored.loadMore(); + expect(store.lists.waiting.error).toBeNull(); + expect(store.lists.waiting.nextCursor).toBe('waiting-2'); + expect(store.lists.authored.nextCursor).toBe('authored-2'); + await store.lists.authored.retry(); + expect(authoredRequest).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ category: 'authored', cursor: 'authored-2' }), + ); + expect(waitingOrClosedRequest).toHaveBeenCalledOnce(); + expect(store.lists.authored.items.map((item) => item.id)).toEqual(['authored', 'older']); +}); + +it('requires both groups to be successfully empty before showing the Open empty state', async () => { + const store = useInboxStore(); + expect(store.isEmpty).toBe(false); + authoredRequest.mockRejectedValueOnce(new Error('timeout')); + await store.refreshListAndSummary(); + expect(store.lists.waiting.hasLoaded).toBe(true); + expect(store.initialLoadFailed).toBe(true); + expect(store.isEmpty).toBe(false); + authoredRequest.mockResolvedValueOnce( + page([], { partial: true, failedSources: ['workflow_review'] }), + ); + await store.lists.authored.retry(); + expect(store.partial).toBe(true); + expect(store.isEmpty).toBe(false); + await store.lists.authored.fetchList(); + expect(store.isEmpty).toBe(true); +}); + +function reviewItem(id: string): InboxItem { + return { + type: 'workflow_review', + id, + projectId: 'project', + title: 'Review', + workflowName: 'Workflow', + requester: null, + authors: [], + reviewers: [], + state: 'open', + decision: 'pending', + workflowVersionId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; +} + +it('updates a review decision without discarding loaded pages', async () => { + const store = useInboxStore(); + store.lists.waiting.items = [reviewItem('first'), reviewItem('older')]; + store.lists.waiting.nextCursor = 'next-page'; + store.reconcileItemChange({ + type: 'workflow_review', + id: 'older', + state: 'open', + decision: 'changes_requested', + updatedAt: '2026-01-02T00:00:00.000Z', + }); + expect(store.lists.waiting.items).toHaveLength(2); + expect(store.lists.waiting.items[1]).toMatchObject({ decision: 'changes_requested' }); + expect(store.lists.waiting.nextCursor).toBe('next-page'); + expect(api.fetchInbox).not.toHaveBeenCalled(); +}); + +it('removes an approved review from Open and updates the counts', async () => { + const store = useInboxStore(); + store.openCount = 2; + store.closedCount = 1; + store.lists.waiting.items = [reviewItem('review')]; + store.reconcileItemChange({ + type: 'workflow_review', + id: 'review', + state: 'closed', + decision: 'approved', + }); + expect(store.lists.waiting.items).toEqual([]); + expect(store.openCount).toBe(1); + expect(store.closedCount).toBe(2); +}); + +it('keeps the counts when a late approval arrives after a refresh already closed the review', async () => { + const store = useInboxStore(); + store.openCount = 1; + store.closedCount = 2; + store.lists.closed.items = [{ ...reviewItem('review'), state: 'closed', decision: 'approved' }]; + store.reconcileItemChange({ type: 'workflow_review', id: 'review', state: 'closed' }); + expect(store.openCount).toBe(1); + expect(store.closedCount).toBe(2); + expect(api.fetchInboxSummary).not.toHaveBeenCalled(); +}); + +it('loads the counts when an approved review is not in a loaded page', async () => { + vi.mocked(api.fetchInboxSummary).mockResolvedValueOnce({ + counts: { open: 1, closed: 2 }, + partial: false, + failedSources: [], + disabledSources: [], + }); + const store = useInboxStore(); + store.openCount = 4; + store.closedCount = 7; + store.reconcileItemChange({ type: 'workflow_review', id: 'review', state: 'closed' }); + await vi.waitFor(() => { + expect(api.fetchInboxSummary).toHaveBeenCalledOnce(); + expect(store.openCount).toBe(1); + expect(store.closedCount).toBe(2); + }); +}); diff --git a/packages/modules/inbox/frontend/src/inbox.store.ts b/packages/modules/inbox/frontend/src/inbox.store.ts new file mode 100644 index 00000000000..6855739445c --- /dev/null +++ b/packages/modules/inbox/frontend/src/inbox.store.ts @@ -0,0 +1,305 @@ +import type { + InboxCategory, + InboxItem, + InboxSourceType, + InboxState, + ListInboxResponse, +} from '@n8n/api-types'; +import { ResponseError } from '@n8n/rest-api-client'; +import { useRootStore } from '@n8n/stores/useRootStore'; +import { useSettingsStore } from '@n8n/stores/settings.store'; +import { defineStore } from 'pinia'; +import { computed, reactive, ref } from 'vue'; + +import { fetchInbox, fetchInboxSummary } from './inbox.api'; +import { INBOX_PAGE_LIMIT, type InboxItemChange, type InboxSectionKey } from './inbox.constants'; +import { toError } from './reviews/workflowReviews.utils'; + +const sourceTypes: InboxSourceType[] = ['workflow_review']; + +type ListState = { + items: InboxItem[]; + nextCursor: string | null; + hasMore: boolean; + hasLoaded: boolean; + loading: boolean; + loadingMore: boolean; + error: Error | null; + failedRequest: 'list' | 'loadMore' | null; + partial: boolean; +}; + +function isSourceType(value: unknown): value is InboxSourceType { + return value === 'workflow_review'; +} + +function disabledTypesFromError(error: unknown): InboxSourceType[] { + const types = error instanceof ResponseError ? error.meta?.disabledSources : undefined; + return Array.isArray(types) ? types.filter(isSourceType) : []; +} + +export function createInboxListSlice( + requestPage: (cursor?: string) => Promise, + onDisabledSources: (types: InboxSourceType[]) => void, + onPage?: (page: ListInboxResponse, append: boolean) => void, +) { + const state = reactive({ + items: [], + nextCursor: null, + hasMore: false, + hasLoaded: false, + loading: false, + loadingMore: false, + error: null, + failedRequest: null, + partial: false, + }); + let requestSeq = 0; + + function applyResponse(page: ListInboxResponse, append: boolean) { + onPage?.(page, append); + onDisabledSources(page.disabledSources); + state.hasLoaded = true; + state.items = append ? [...state.items, ...page.data] : page.data; + state.nextCursor = page.nextCursor; + state.hasMore = page.hasMore; + state.partial = page.partial; + } + + async function fetchList() { + const seq = ++requestSeq; + state.items = []; + state.nextCursor = null; + state.hasMore = false; + state.loading = true; + state.loadingMore = false; + state.error = null; + state.failedRequest = null; + try { + const page = await requestPage(); + if (seq === requestSeq) applyResponse(page, false); + } catch (error) { + if (seq !== requestSeq) return; + onDisabledSources(disabledTypesFromError(error)); + state.error = toError(error); + state.failedRequest = 'list'; + } finally { + if (seq === requestSeq) state.loading = false; + } + } + + async function loadMore() { + if (state.loading || state.loadingMore || !state.hasMore || !state.nextCursor) return; + const seq = ++requestSeq; + state.loadingMore = true; + state.error = null; + state.failedRequest = null; + try { + const page = await requestPage(state.nextCursor); + if (seq === requestSeq) applyResponse(page, true); + } catch (error) { + if (seq !== requestSeq) return; + onDisabledSources(disabledTypesFromError(error)); + state.error = toError(error); + state.failedRequest = 'loadMore'; + } finally { + if (seq === requestSeq) state.loadingMore = false; + } + } + + function removeSources(types: InboxSourceType[]) { + state.items = state.items.filter((item) => !types.includes(item.type)); + } + + function reset() { + requestSeq++; + state.items = []; + state.hasLoaded = false; + state.nextCursor = null; + state.hasMore = false; + state.loading = false; + state.loadingMore = false; + state.error = null; + state.failedRequest = null; + state.partial = false; + } + + return Object.assign(state, { + fetchList, + loadMore, + removeSources, + reset, + async retry() { + if (state.failedRequest === 'loadMore') await loadMore(); + else await fetchList(); + }, + }); +} + +export const useInboxStore = defineStore('inbox', () => { + const rootStore = useRootStore(); + const settingsStore = useSettingsStore(); + const activeTab = ref('open'); + const openCount = ref(null); + const closedCount = ref(null); + const disabledSources = ref([]); + const enabled = computed(() => settingsStore.settings.inbox?.enabled === true); + let summaryRequestSeq = 0; + + function removeDisabledSources(types: InboxSourceType[]) { + if (types.length === 0) return; + disabledSources.value = [...new Set([...disabledSources.value, ...types])]; + for (const list of Object.values(lists)) list.removeSources(types); + } + + function reconcileSources(page: ListInboxResponse, partialSourceSet: boolean) { + if (partialSourceSet) { + const returnedTypes = page.data.map((item) => item.type); + disabledSources.value = disabledSources.value.filter( + (type) => page.disabledSources.includes(type) || !returnedTypes.includes(type), + ); + return; + } + const settings = settingsStore.settings.inbox; + const knownTypes = new Set([ + ...(settings?.availableTypes ?? []), + ...(settings?.failedTypes ?? []), + ...page.data.map((item) => item.type), + ]); + disabledSources.value = sourceTypes.filter( + (type) => + !knownTypes.has(type) || + page.disabledSources.includes(type) || + (disabledSources.value.includes(type) && page.failedSources.includes(type)), + ); + } + + function requestPage(state: InboxState, category?: InboxCategory) { + return async (cursor?: string) => + await fetchInbox(rootStore.restApiContext, { + state, + category, + limit: INBOX_PAGE_LIMIT, + cursor, + }); + } + const lists = { + waiting: createInboxListSlice( + requestPage('open', 'waiting'), + removeDisabledSources, + reconcileSources, + ), + // A category can query only part of the source set. + authored: createInboxListSlice(requestPage('open', 'authored'), removeDisabledSources, (page) => + reconcileSources(page, true), + ), + closed: createInboxListSlice(requestPage('closed'), removeDisabledSources, reconcileSources), + }; + const activeSectionKeys = computed(() => + activeTab.value === 'closed' ? ['closed'] : ['waiting', 'authored'], + ); + const activeLists = computed(() => activeSectionKeys.value.map((key) => lists[key])); + const hasItems = computed(() => activeLists.value.some((list) => list.items.length > 0)); + const loading = computed(() => activeLists.value.some((list) => list.loading)); + const initialLoadFailed = computed(() => + activeLists.value.some((list) => list.failedRequest === 'list'), + ); + const partial = computed(() => activeLists.value.some((list) => list.partial)); + const isEmpty = computed(() => + activeLists.value.every( + (list) => + list.hasLoaded && + !list.loading && + !list.error && + !list.partial && + !list.hasMore && + list.items.length === 0, + ), + ); + + async function fetchSummary() { + if (!enabled.value) return; + const seq = ++summaryRequestSeq; + try { + const summary = await fetchInboxSummary(rootStore.restApiContext); + if (seq !== summaryRequestSeq) return; + removeDisabledSources(summary.disabledSources); + openCount.value = summary.counts?.open ?? null; + closedCount.value = summary.counts?.closed ?? null; + } catch (error) { + if (seq !== summaryRequestSeq) return; + removeDisabledSources(disabledTypesFromError(error)); + } + } + + async function fetchActiveTab() { + if (!enabled.value) return; + await Promise.all(activeLists.value.map(async (list) => await list.fetchList())); + } + + async function refreshListAndSummary() { + await Promise.all([fetchActiveTab(), fetchSummary()]); + } + + function reconcileItemChange(change: InboxItemChange) { + let previousState: InboxState | undefined; + for (const [key, list] of Object.entries(lists)) { + const item = list.items.find((row) => row.type === change.type && row.id === change.id); + if (!item) continue; + previousState ??= item.state; + if (change.state) item.state = change.state; + if (change.updatedAt) item.updatedAt = change.updatedAt; + if (item.type === 'workflow_review' && change.decision) item.decision = change.decision; + if (item.state !== (key === 'closed' ? 'closed' : 'open')) { + list.items = list.items.filter((row) => row !== item); + } + } + if (change.state !== 'closed') return; + // A late result can arrive after a summary that already counts it. + // Count only a row that closes now. Without a row, ask the server. + if (previousState === 'open') { + if (openCount.value !== null) openCount.value = Math.max(0, openCount.value - 1); + if (closedCount.value !== null) closedCount.value++; + } else if (!previousState) { + void fetchSummary(); + } + } + + async function setActiveTab(tab: InboxState) { + if (activeTab.value === tab) return; + activeTab.value = tab; + await fetchActiveTab(); + } + + function reset() { + summaryRequestSeq++; + for (const list of Object.values(lists)) list.reset(); + openCount.value = null; + closedCount.value = null; + const settings = settingsStore.settings.inbox; + const available = [...(settings?.availableTypes ?? []), ...(settings?.failedTypes ?? [])]; + disabledSources.value = sourceTypes.filter((type) => !available.includes(type)); + } + + return { + lists, + activeTab, + activeSectionKeys, + activeLists, + hasItems, + loading, + initialLoadFailed, + partial, + isEmpty, + enabled, + openCount, + closedCount, + disabledSources, + fetchSummary, + fetchActiveTab, + refreshListAndSummary, + reconcileItemChange, + setActiveTab, + reset, + }; +}); diff --git a/packages/modules/inbox/frontend/src/index.ts b/packages/modules/inbox/frontend/src/index.ts new file mode 100644 index 00000000000..8d562aec450 --- /dev/null +++ b/packages/modules/inbox/frontend/src/index.ts @@ -0,0 +1,5 @@ +export { InboxModule } from './inbox.module'; +export { INBOX_VIEW } from './inbox.constants'; +export { isInboxRoute } from './inbox.routes'; +export { WORKFLOW_REVIEW_REQUESTS_VIEW, REVIEW_INBOX_QUERY_PARAM } from './reviews/constants'; +export { formatUserDisplayName } from './reviews/workflowReviews.utils'; diff --git a/packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.test.ts b/packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.test.ts new file mode 100644 index 00000000000..af6dc9ef94a --- /dev/null +++ b/packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.test.ts @@ -0,0 +1,647 @@ +import type { + DecideWorkflowReviewRequestResponse, + InboxWorkflowReviewItem, + WorkflowReviewRequestDetail, +} from '@n8n/api-types'; +import { useToast } from '@n8n/composables/useToast'; +import { createComponentRenderer, mockedStore, waitAllPromises } from '@n8n/frontend-test-utils'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; + +import { useReviewActivityStore } from './reviewActivity.store'; +import { useReviewDetailStore } from './reviewDetail.store'; +import WorkflowReviewDetail from './WorkflowReviewDetail.vue'; +import * as api from './workflowReviews.api'; + +vi.mock('./workflowReviews.api'); +vi.mock('@n8n/composables/useToast', () => ({ useToast: vi.fn() })); +vi.mock('./components/WorkflowReviewChangesSection.vue', () => ({ + default: { template: '
' }, +})); +const showError = vi.fn(); +const showMessage = vi.fn(); +const onItemChange = vi.fn(); +let isInboxActive = true; +let selectedReviewId: string | null = 'req-1'; +const renderComponent = createComponentRenderer(WorkflowReviewDetail, { + props: { + reviewId: 'req-1', + onItemChange, + isActive: () => isInboxActive, + isSelected: (id: string) => selectedReviewId === id, + }, + global: { + stubs: { + WorkflowReviewDecisionPopover: { + props: ['deciding', 'viewerCanDecide', 'viewerCanComment', 'ineligibilityHint'], + template: `
+
`, + }, + }, + }, +}); + +const decisionResponse = ( + overrides: Partial = {}, +): DecideWorkflowReviewRequestResponse => ({ + id: 'req-1', + state: 'closed', + decision: 'approved', + workflowVersionId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + ...overrides, +}); + +let reviewStore: ReturnType>; +let activityStore: ReturnType>; +beforeEach(() => { + createTestingPinia(); + isInboxActive = true; + selectedReviewId = 'req-1'; + vi.clearAllMocks(); + vi.mocked(useToast).mockReturnValue({ showError, showMessage } as unknown as ReturnType< + typeof useToast + >); + reviewStore = mockedStore(useReviewDetailStore); + reviewStore.fetchDetail.mockResolvedValue(undefined); + reviewStore.detail = createDetail(); + reviewStore.decideOnReview.mockResolvedValue(decisionResponse()); + activityStore = mockedStore(useReviewActivityStore); + activityStore.currentReviewId = 'req-1'; + activityStore.fetchFeed.mockResolvedValue(undefined); +}); + +describe('review decisions', () => { + it('offers no decision actions for a closed review', async () => { + reviewStore.detail = createDetail({ state: 'closed', decision: 'approved' }); + + const { queryByTestId } = renderComponent(); + await waitAllPromises(); + + expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument(); + }); + it('submits an approval for the selected review', async () => { + const { getByTestId } = renderComponent(); + await waitAllPromises(); + + getByTestId('approve-review').click(); + await waitAllPromises(); + + expect(reviewStore.decideOnReview).toHaveBeenCalledWith('req-1', { decision: 'approved' }); + expect(showError).not.toHaveBeenCalled(); + }); + it('refetches the detail after a decision on the selected review', async () => { + const { getByTestId } = renderComponent(); + await waitAllPromises(); + reviewStore.fetchDetail.mockClear(); + + getByTestId('approve-review').click(); + await waitAllPromises(); + + expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1'); + }); + it('submits a change request for the selected review', async () => { + const { getByTestId } = renderComponent(); + await waitAllPromises(); + + getByTestId('request-changes').click(); + await waitAllPromises(); + + expect(reviewStore.decideOnReview).toHaveBeenCalledWith('req-1', { + decision: 'changes_requested', + note: 'needs work', + }); + }); + it('shows a success toast when the approval published the workflow', async () => { + reviewStore.decideOnReview.mockResolvedValueOnce( + decisionResponse({ autoPublish: { status: 'published' } }), + ); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + + getByTestId('approve-review').click(); + await waitAllPromises(); + + expect(showMessage).toHaveBeenCalledWith({ + type: 'success', + title: 'Review approved', + message: 'The reviewed workflow version has been published.', + }); + expect(showError).not.toHaveBeenCalled(); + }); + it('shows a warning toast with the reason when the auto-publish failed', async () => { + reviewStore.decideOnReview.mockResolvedValueOnce( + decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }), + ); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + + getByTestId('approve-review').click(); + await waitAllPromises(); + + expect(showMessage).toHaveBeenCalledWith({ + type: 'warning', + duration: 0, + title: 'Review approved, but the workflow is not published', + message: 'Version not found. Publish the workflow manually to retry.', + }); + expect(showError).not.toHaveBeenCalled(); + }); + it('does not double up punctuation on an already-terminated message', async () => { + reviewStore.decideOnReview.mockResolvedValueOnce( + decisionResponse({ + autoPublish: { status: 'failed', message: 'Cannot activate an archived workflow.' }, + }), + ); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + + getByTestId('approve-review').click(); + await waitAllPromises(); + + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Cannot activate an archived workflow. Publish the workflow manually to retry.', + }), + ); + }); + it('shows no publish toast when requesting changes', async () => { + reviewStore.decideOnReview.mockResolvedValueOnce( + decisionResponse({ state: 'open', decision: 'changes_requested' }), + ); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + reviewStore.fetchDetail.mockClear(); + + getByTestId('request-changes').click(); + await waitAllPromises(); + + expect(showMessage).not.toHaveBeenCalled(); + expect(reviewStore.fetchDetail).not.toHaveBeenCalled(); + }); + it('shows an error toast when the decision fails', async () => { + const error = new Error('forbidden'); + reviewStore.decideOnReview.mockRejectedValueOnce(error); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + await waitAllPromises(); + + expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision'); + }); + it('refreshes detail and activity and reports a failed decision', async () => { + reviewStore.decideOnReview.mockRejectedValueOnce(new Error('conflict')); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + reviewStore.fetchDetail.mockClear(); + activityStore.fetchFeed.mockClear(); + getByTestId('approve-review').click(); + await waitAllPromises(); + + expect(onItemChange).toHaveBeenCalledWith({ type: 'workflow_review', id: 'req-1' }); + expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1'); + // The other reviewer's decision entry is in the feed, not in the detail payload. + expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); + // The note stays, so the reviewer can retry with it. + expect(activityStore.clearDecisionNote).not.toHaveBeenCalled(); + }); + it('shows the submitted decision in the feed and drops the note behind it', async () => { + reviewStore.decideOnReview.mockResolvedValueOnce( + decisionResponse({ state: 'open', decision: 'changes_requested' }), + ); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + activityStore.fetchFeed.mockClear(); + + getByTestId('request-changes').click(); + await waitAllPromises(); + + expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); + // The note it submitted, so a note typed while the decision was in flight survives. + expect(activityStore.clearDecisionNote).toHaveBeenCalledWith('needs work'); + }); + it('leaves the feed alone when the decision lands after the viewer moved on', async () => { + let resolveDecision!: () => void; + reviewStore.decideOnReview.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveDecision = () => + resolve(decisionResponse({ state: 'open', decision: 'changes_requested' })); + }), + ); + + const { getByTestId, rerender } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + selectedReviewId = 'req-2'; + await rerender({ reviewId: 'req-2' }); + await waitAllPromises(); + activityStore.fetchFeed.mockClear(); + + resolveDecision(); + await waitAllPromises(); + + expect(activityStore.fetchFeed).not.toHaveBeenCalled(); + expect(activityStore.clearDecisionNote).not.toHaveBeenCalled(); + }); + it('shows no publish toast once the viewer has left the page', async () => { + let resolveDecision!: () => void; + reviewStore.decideOnReview.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveDecision = () => + resolve( + decisionResponse({ + autoPublish: { status: 'failed', message: 'Version not found' }, + }), + ); + }), + ); + + const { getByTestId, unmount } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + isInboxActive = false; + unmount(); + + resolveDecision(); + await waitAllPromises(); + + expect(showMessage).not.toHaveBeenCalled(); + }); + it.each(['another review', 'no review'])( + 'reports a publication failure after the viewer selects %s', + async (selection) => { + const pending = createDeferredPromise(); + reviewStore.decideOnReview.mockReturnValueOnce(pending.promise); + const { getByTestId, rerender, unmount } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + if (selection === 'another review') { + selectedReviewId = 'req-2'; + await rerender({ reviewId: 'req-2' }); + } else { + selectedReviewId = null; + unmount(); + } + await waitAllPromises(); + activityStore.fetchFeed.mockClear(); + pending.resolve( + decisionResponse({ autoPublish: { status: 'failed', message: 'Version not found' } }), + ); + await waitAllPromises(); + + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + duration: 0, + message: 'Version not found. Publish the workflow manually to retry.', + }), + ); + expect(activityStore.fetchFeed).not.toHaveBeenCalled(); + }, + ); + it('reports a failed decision after the viewer selects another review', async () => { + const pending = createDeferredPromise(); + reviewStore.decideOnReview.mockReturnValueOnce(pending.promise); + const { getByTestId, rerender } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + selectedReviewId = 'req-2'; + activityStore.currentReviewId = 'req-2'; + await rerender({ reviewId: 'req-2' }); + await waitAllPromises(); + activityStore.fetchFeed.mockClear(); + reviewStore.fetchDetail.mockClear(); + const error = new Error('conflict'); + pending.reject(error); + await waitAllPromises(); + + expect(showError).toHaveBeenCalledWith(error, 'Could not submit review decision'); + expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-2'); + expect(activityStore.fetchFeed).not.toHaveBeenCalled(); + }); + it('shows no failure toast once the viewer has left the page', async () => { + let rejectDecision!: (error: Error) => void; + reviewStore.decideOnReview.mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + rejectDecision = reject; + }), + ); + + const { getByTestId, unmount } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + isInboxActive = false; + unmount(); + + rejectDecision(new Error('conflict')); + await waitAllPromises(); + + expect(showError).not.toHaveBeenCalled(); + expect(onItemChange).toHaveBeenCalledWith({ type: 'workflow_review', id: 'req-1' }); + }); + it('locks the decision actions while a decision is in flight', async () => { + let resolveDecision!: () => void; + reviewStore.decideOnReview.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveDecision = () => resolve(decisionResponse()); + }), + ); + + const { getByTestId } = renderComponent(); + await waitAllPromises(); + + getByTestId('approve-review').click(); + await vi.waitFor(() => { + expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute( + 'data-deciding', + 'true', + ); + }); + + resolveDecision(); + await waitAllPromises(); + + expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute( + 'data-deciding', + 'false', + ); + }); + it('keeps decisions locked after selection changes until the submitted action completes', async () => { + const pending = createDeferredPromise(); + reviewStore.decideOnReview.mockReturnValueOnce(pending.promise); + const { getByTestId, rerender } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + reviewStore.detail = createDetail({ id: 'req-2' }); + selectedReviewId = 'req-2'; + await rerender({ reviewId: 'req-2' }); + await waitAllPromises(); + + expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute( + 'data-deciding', + 'true', + ); + getByTestId('approve-review').click(); + expect(reviewStore.decideOnReview).toHaveBeenCalledOnce(); + pending.resolve(decisionResponse()); + await waitAllPromises(); + expect(getByTestId('workflow-review-decision-popover')).toHaveAttribute( + 'data-deciding', + 'false', + ); + }); +}); + +describe('review detail ownership', () => { + it('loads the selected review and activity on entry', async () => { + renderComponent(); + await waitAllPromises(); + expect(reviewStore.clearDetail).toHaveBeenCalledOnce(); + expect(activityStore.reset).toHaveBeenCalledOnce(); + expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1'); + expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); + }); + + it('uses the list title while detail is loading', () => { + reviewStore.detail = null; + reviewStore.detailLoading = true; + const { getByTestId } = renderComponent({ props: { listItem: createInboxItem() } }); + expect(getByTestId('workflow-review-request-title')).toHaveTextContent('List review'); + }); + + it('keeps the list title and row when detail reports a missing review', async () => { + const { getByTestId } = renderComponent(); + reviewStore.detailNotFound = true; + await waitAllPromises(); + expect(onItemChange).not.toHaveBeenCalled(); + expect(getByTestId('workflow-review-detail-not-found')).toBeInTheDocument(); + expect(getByTestId('workflow-review-request-title')).toBeInTheDocument(); + }); + + it('shows the Inbox fallback after a failed detail load without a list item', async () => { + reviewStore.detail = null; + const error = new Error('timeout'); + reviewStore.fetchDetail.mockRejectedValueOnce(error); + const { getByTestId } = renderComponent({ + slots: { default: '
Select an item
' }, + }); + await waitAllPromises(); + expect(getByTestId('inbox-fallback')).toBeInTheDocument(); + expect(showError).toHaveBeenCalledWith(error, 'Could not load workflow reviews'); + }); + + it('refreshes the current review after returning to it and keeps its new note', async () => { + const pending = createDeferredPromise(); + reviewStore.clearDetail.mockImplementation(() => { + reviewStore.selectionRevision++; + }); + reviewStore.decideOnReview.mockReturnValueOnce(pending.promise); + const { getByTestId, rerender } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + selectedReviewId = 'req-2'; + await rerender({ reviewId: 'req-2' }); + selectedReviewId = 'req-1'; + await rerender({ reviewId: 'req-1' }); + activityStore.decisionNote = 'New note'; + activityStore.fetchFeed.mockClear(); + pending.resolve(decisionResponse({ autoPublish: { status: 'published' } })); + await waitAllPromises(); + expect(activityStore.clearDecisionNote).not.toHaveBeenCalled(); + expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); + expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + expect(activityStore.decisionNote).toBe('New note'); + expect(onItemChange).toHaveBeenCalledWith({ + type: 'workflow_review', + id: 'req-1', + state: 'closed', + decision: 'approved', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + }); + it.each(['loaded', 'loading'])( + 'shows closed detail and saved activity after returning while approval is pending and detail is %s', + async (detailState) => { + createTestingPinia({ stubActions: false }); + reviewStore = mockedStore(useReviewDetailStore); + activityStore = mockedStore(useReviewActivityStore); + const pending = createDeferredPromise(); + const detailRequest = createDeferredPromise(); + vi.mocked(api.decideWorkflowReviewRequest).mockReturnValueOnce(pending.promise); + vi.mocked(api.fetchWorkflowReviewRequestDetail).mockImplementation(async (_context, id) => + createDetail({ id }), + ); + vi.mocked(api.fetchWorkflowReviewActivity).mockResolvedValue({ + data: [], + nextCursor: null, + hasMore: false, + }); + const { getByTestId, queryByTestId, rerender } = renderComponent(); + await waitAllPromises(); + getByTestId('approve-review').click(); + selectedReviewId = 'req-2'; + await rerender({ reviewId: 'req-2' }); + await waitAllPromises(); + if (detailState === 'loading') { + vi.mocked(api.fetchWorkflowReviewRequestDetail).mockReturnValueOnce(detailRequest.promise); + } + selectedReviewId = 'req-1'; + await rerender({ reviewId: 'req-1' }); + await waitAllPromises(); + activityStore.decisionNote = 'New note'; + vi.mocked(api.fetchWorkflowReviewRequestDetail).mockResolvedValue( + createDetail({ state: 'closed', decision: 'approved' }), + ); + vi.mocked(api.fetchWorkflowReviewActivity).mockResolvedValue({ + data: [ + { + id: '2', + type: 'review.approved', + typeVersion: 1, + data: null, + createdBy: null, + createdAt: '2024-01-02T00:00:00.000Z', + }, + ], + nextCursor: null, + hasMore: false, + }); + pending.resolve(decisionResponse()); + await waitAllPromises(); + detailRequest.resolve(createDetail()); + await waitAllPromises(); + + expect(reviewStore.detail?.state).toBe('closed'); + expect(activityStore.entries).toEqual([expect.objectContaining({ type: 'review.approved' })]); + expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument(); + expect(activityStore.decisionNote).toBe('New note'); + }, + ); + it('reports a failed comment after the viewer switches from Activity to Changes', async () => { + const pending = createDeferredPromise(); + activityStore.postComment.mockReturnValueOnce(pending.promise); + activityStore.draft = 'Sent comment'; + const { getByTestId, queryByTestId, rerender } = renderComponent(); + await waitAllPromises(); + await userEvent.click(getByTestId('send-message-button')); + await rerender({ tab: 'changes' }); + expect(queryByTestId('workflow-review-comment-composer')).not.toBeInTheDocument(); + const error = new Error('timeout'); + pending.reject(error); + await waitAllPromises(); + + expect(showError).toHaveBeenCalledWith(error, 'Could not post comment'); + expect(activityStore.draft).toBe('Sent comment'); + }); + + it('refreshes a reselected review after its prior detail entry unmounts', async () => { + const pending = createDeferredPromise(); + reviewStore.clearDetail.mockImplementation(() => { + reviewStore.selectionRevision++; + }); + reviewStore.decideOnReview.mockReturnValueOnce(pending.promise); + const first = renderComponent(); + await waitAllPromises(); + first.getByTestId('approve-review').click(); + first.unmount(); + renderComponent(); + await waitAllPromises(); + activityStore.decisionNote = 'New note'; + activityStore.fetchFeed.mockClear(); + reviewStore.fetchDetail.mockClear(); + pending.resolve( + decisionResponse({ autoPublish: { status: 'failed', message: 'Not published' } }), + ); + await waitAllPromises(); + expect(onItemChange).toHaveBeenCalledWith({ + type: 'workflow_review', + id: 'req-1', + state: 'closed', + decision: 'approved', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + expect(activityStore.clearDecisionNote).not.toHaveBeenCalled(); + expect(activityStore.fetchFeed).toHaveBeenCalledWith('req-1'); + expect(reviewStore.fetchDetail).toHaveBeenCalledWith('req-1'); + expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' })); + expect(activityStore.decisionNote).toBe('New note'); + }); + + it('does not reset shared stores when an older layout unmounts', async () => { + reviewStore.clearDetail.mockImplementation(() => { + reviewStore.selectionRevision++; + }); + const first = renderComponent(); + renderComponent(); + await waitAllPromises(); + activityStore.draft = 'Current draft'; + reviewStore.clearDetail.mockClear(); + activityStore.reset.mockClear(); + first.unmount(); + expect(reviewStore.clearDetail).not.toHaveBeenCalled(); + expect(activityStore.reset).not.toHaveBeenCalled(); + expect(activityStore.draft).toBe('Current draft'); + }); + + it('ignores an old detail failure after a new layout claims the same review', async () => { + const pending = createDeferredPromise(); + reviewStore.clearDetail.mockImplementation(() => { + reviewStore.selectionRevision++; + }); + reviewStore.fetchDetail.mockReturnValueOnce(pending.promise); + renderComponent(); + renderComponent(); + await waitAllPromises(); + pending.reject(new Error('timeout')); + await waitAllPromises(); + expect(showError).not.toHaveBeenCalled(); + }); +}); + +function createInboxItem(): InboxWorkflowReviewItem { + return { + type: 'workflow_review', + id: 'req-1', + projectId: 'proj-1', + title: 'List review', + workflowName: 'My workflow', + workflowVersionId: null, + requester: null, + authors: [], + reviewers: [], + decision: 'pending', + state: 'open', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; +} + +function createDetail( + overrides: Partial = {}, +): WorkflowReviewRequestDetail { + return { + ...createInboxItem(), + description: null, + workflows: [], + viewerCanDecide: true, + viewerDecisionIneligibilityReason: null, + viewerCanComment: true, + ...overrides, + }; +} diff --git a/packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.vue b/packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.vue new file mode 100644 index 00000000000..822cdbc1a6b --- /dev/null +++ b/packages/modules/inbox/frontend/src/reviews/WorkflowReviewDetail.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewActivityFeed.test.ts b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewActivityFeed.test.ts similarity index 99% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewActivityFeed.test.ts rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewActivityFeed.test.ts index 6c4bd2e07cd..8c28792d46c 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewActivityFeed.test.ts +++ b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewActivityFeed.test.ts @@ -1,7 +1,6 @@ import type { WorkflowReviewActivityEntry } from '@n8n/api-types'; import { createTestingPinia } from '@pinia/testing'; -import { createComponentRenderer } from '@/__tests__/render'; -import { mockedStore } from '@/__tests__/utils'; +import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils'; import { computed, nextTick, ref } from 'vue'; import { @@ -24,7 +23,7 @@ const observer = vi.hoisted(() => ({ onIntersect: () => {}, })); -vi.mock('@/app/composables/useIntersectionObserver', () => ({ +vi.mock('@n8n/composables/useIntersectionObserver', () => ({ useIntersectionObserver: (options: { onIntersect: () => void }) => { observer.onIntersect = options.onIntersect; return { observe: observer.observe, disconnect: vi.fn(), observer: { value: null } }; @@ -36,8 +35,8 @@ const resize = vi.hoisted(() => ({ onResize: (_entries: ResizeObserverEntry[]) = vi.mock('@vueuse/core', async (importOriginal) => ({ ...(await importOriginal()), - useResizeObserver: (_target: unknown, callback: (entries: ResizeObserverEntry[]) => void) => { - resize.onResize = callback; + useResizeObserver: (_target: unknown, onResize: (entries: ResizeObserverEntry[]) => void) => { + resize.onResize = onResize; }, })); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewActivityFeed.vue b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewActivityFeed.vue similarity index 99% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewActivityFeed.vue rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewActivityFeed.vue index cb0b4eab729..5c035986ea0 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewActivityFeed.vue +++ b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewActivityFeed.vue @@ -5,7 +5,7 @@ import { useResizeObserver } from '@vueuse/core'; import { storeToRefs } from 'pinia'; import { inject, onMounted, ref, watch } from 'vue'; -import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver'; +import { useIntersectionObserver } from '@n8n/composables/useIntersectionObserver'; import { ReviewDetailScrollContainerKey } from '../constants'; import { useReviewActivityStore } from '../reviewActivity.store'; diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewChangesSection.test.ts b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewChangesSection.test.ts similarity index 80% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewChangesSection.test.ts rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewChangesSection.test.ts index cb18c60b634..54bac6936d4 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewChangesSection.test.ts +++ b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewChangesSection.test.ts @@ -6,34 +6,40 @@ import type { } from '@n8n/api-types'; import { createTestingPinia } from '@pinia/testing'; import type { INode } from 'n8n-workflow'; -import { isReactive, reactive } from 'vue'; -import { createComponentRenderer } from '@/__tests__/render'; -import type { IWorkflowDb } from '@/Interface'; +import { defineComponent } from 'vue'; +import { createComponentRenderer } from '@n8n/frontend-test-utils'; +import { componentRegistry, type SlotWorkflowDiffProps } from '@n8n/frontend-module-sdk'; import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue'; -type DiffViewProps = { - sourceWorkflow?: IWorkflowDb; - targetWorkflow?: IWorkflowDb; - sourceLabel?: string; - targetLabel?: string; -}; +type DiffViewProps = SlotWorkflowDiffProps; const { diffViewProps } = vi.hoisted(() => ({ diffViewProps: [] as DiffViewProps[], })); -vi.mock('@/features/workflows/workflowDiff/WorkflowDiffView.vue', () => ({ - default: { - name: 'WorkflowDiffView', - props: ['sourceWorkflow', 'targetWorkflow', 'sourceLabel', 'targetLabel'], - setup(props: DiffViewProps) { - diffViewProps.push(props); - }, - template: - '
', +const DiffView = defineComponent({ + name: 'WorkflowDiffView', + props: [ + 'sourceSnapshot', + 'targetSnapshot', + 'workflowId', + 'workflowName', + 'sourceLabel', + 'targetLabel', + 'showFullscreenButton', + ], + setup(props) { + diffViewProps.push(props as SlotWorkflowDiffProps); }, -})); + template: + '
', +}); +beforeEach(() => { + componentRegistry.clear(); + componentRegistry.register('workflow-diff', DiffView); +}); +afterEach(() => componentRegistry.clear()); const renderSection = createComponentRenderer(WorkflowReviewChangesSection); @@ -206,30 +212,26 @@ describe('WorkflowReviewChangesSection', () => { expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument(); expect(diffViewProps).toHaveLength(1); - expect(diffViewProps[0].sourceWorkflow).toBeUndefined(); - expect(diffViewProps[0].targetWorkflow?.id).toBe('wf-1'); + expect(diffViewProps[0].sourceSnapshot).toBeUndefined(); + expect(diffViewProps[0].workflowId).toBe('wf-1'); expect(queryByTestId('workflow-review-changes-source-label')).not.toBeInTheDocument(); expect(getByTestId('workflow-review-changes-target-label')).toBeInTheDocument(); }); - it('hands the diff detached workflows built from the snapshots', () => { + it('passes workflow identity, snapshots, and labels to the editor renderer', () => { const workflow = makeWorkflow(); renderComponent({ props: { workflow } }); expect(diffViewProps).toHaveLength(1); - const { sourceWorkflow, targetWorkflow, sourceLabel, targetLabel } = diffViewProps[0]; + const { sourceSnapshot, targetSnapshot, sourceLabel, targetLabel } = diffViewProps[0]; - expect(sourceWorkflow?.id).toBe('wf-1'); - expect(sourceWorkflow?.name).toBe('Payment handler'); - expect(sourceWorkflow?.versionId).toBe('0f123890-0000-0000-0000-000000000000'); - expect(sourceWorkflow?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']); + expect(diffViewProps[0].workflowId).toBe('wf-1'); + expect(diffViewProps[0].workflowName).toBe('Payment handler'); + expect(sourceSnapshot?.versionId).toBe('0f123890-0000-0000-0000-000000000000'); + expect(sourceSnapshot?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']); - expect(targetWorkflow?.versionId).toBe('77b70644-0000-0000-0000-000000000000'); - expect(targetWorkflow?.nodes.map((node) => node.id)).toEqual(['node-1']); - - // Detached copies: not sharing references with the DTO, so the diff - // canvases can't feed writes back into store state. - expect(targetWorkflow?.nodes).not.toBe(workflow.pinnedVersion?.nodes); + expect(targetSnapshot?.versionId).toBe('77b70644-0000-0000-0000-000000000000'); + expect(targetSnapshot?.nodes.map((node) => node.id)).toEqual(['node-1']); expect(sourceLabel).toContain('Published'); expect(sourceLabel).toContain('0f123890'); @@ -237,23 +239,6 @@ describe('WorkflowReviewChangesSection', () => { expect(targetLabel).toContain('77b70644'); }); - // Feed a genuinely reactive prop, The guard matters — a reactive source lets - // WorkflowDiffView's position writes drive a hydrate → dispose → hydrate loop - // that pins the main thread. - it('detaches the handed-off workflows from a reactive source', () => { - const workflow = reactive(makeWorkflow()); - renderComponent({ props: { workflow } }); - - expect(diffViewProps).toHaveLength(1); - const { sourceWorkflow, targetWorkflow } = diffViewProps[0]; - - expect(isReactive(workflow.pinnedVersion)).toBe(true); - expect(isReactive(sourceWorkflow)).toBe(false); - expect(isReactive(targetWorkflow)).toBe(false); - expect(isReactive(targetWorkflow?.nodes)).toBe(false); - expect(isReactive(targetWorkflow?.nodes[0])).toBe(false); - }); - describe('closed reviews', () => { const approved = { state: 'closed', decision: 'approved' } as const; @@ -288,7 +273,7 @@ describe('WorkflowReviewChangesSection', () => { }); expect(getByTestId('workflow-diff-view-stub')).toBeInTheDocument(); - expect(diffViewProps[0].sourceWorkflow).toBeUndefined(); + expect(diffViewProps[0].sourceSnapshot).toBeUndefined(); expect( queryByTestId('workflow-review-changes-closed-without-approval'), ).not.toBeInTheDocument(); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewChangesSection.vue b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewChangesSection.vue similarity index 81% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewChangesSection.vue rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewChangesSection.vue index 71f76a2c1de..1313fca83a7 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewChangesSection.vue +++ b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewChangesSection.vue @@ -7,14 +7,14 @@ import type { } from '@n8n/api-types'; import { N8nCallout, N8nText } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; -import { deepCopy } from 'n8n-workflow'; import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; -import { computed, markRaw } from 'vue'; +import { computed } from 'vue'; +import { componentRegistry } from '@n8n/frontend-module-sdk'; -import WorkflowDiffView from '@/features/workflows/workflowDiff/WorkflowDiffView.vue'; -import { getVersionLabel } from '@/features/workflows/workflowHistory/utils'; -import type { IWorkflowDb } from '@/Interface'; +import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel'; + +const WorkflowDiffView = computed(() => componentRegistry.get('workflow-diff')); const props = defineProps<{ workflow: WorkflowReviewRequestWorkflowDetail; @@ -58,32 +58,6 @@ const hasChanges = computed(() => { return !isEqual(contentOf(baselineVersion), contentOf(pinnedVersion)); }); -// Hand the diff a fully-detached, non-reactive copy. -function snapshotToWorkflow(snapshot: WorkflowReviewVersionSnapshot): IWorkflowDb { - return markRaw( - deepCopy({ - id: props.workflow.workflowId, - name: props.workflow.workflowName, - active: false, - isArchived: false, - createdAt: snapshot.createdAt, - updatedAt: snapshot.createdAt, - versionId: snapshot.versionId, - activeVersionId: null, - nodes: snapshot.nodes, - connections: snapshot.connections, - nodeGroups: snapshot.nodeGroups, - }), - ); -} - -const sourceWorkflow = computed(() => - props.workflow.baselineVersion ? snapshotToWorkflow(props.workflow.baselineVersion) : undefined, -); -const targetWorkflow = computed(() => - props.workflow.pinnedVersion ? snapshotToWorkflow(props.workflow.pinnedVersion) : undefined, -); - // The dots track publish status, which approval flips: the approved version is // the published one now, and the baseline it replaced no longer is. const sourceDotClass = computed(() => @@ -156,9 +130,12 @@ const targetLabel = computed(() => {{ noChangesText }}
- - +
@@ -191,7 +168,24 @@ const targetLabel = computed(() => } .versionBadge { - composes: sourceBadge from '../../workflows/workflowDiff/workflowDiff.module.scss'; + /* Keep the existing Reviews badge sizes when the root font changes. */ + --review-version-badge--spacing: 12px; + --review-version-badge--radius: 4px; + --review-version-badge--height: 30px; + --review-version-badge--gap: 8px; + position: absolute; + top: var(--review-version-badge--spacing); + left: var(--review-version-badge--spacing); + z-index: 1; + border-radius: var(--review-version-badge--radius); + border: var(--border-width) solid var(--color--foreground--tint-1); + background: var(--color--foreground--tint-2); + display: flex; + height: var(--review-version-badge--height); + padding: 0 var(--review-version-badge--spacing); + align-items: center; + gap: var(--review-version-badge--gap); + align-self: stretch; } .statusDot { diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewCommentComposer.test.ts b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewCommentComposer.test.ts similarity index 80% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewCommentComposer.test.ts rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewCommentComposer.test.ts index 7cabb307ac4..0b78786bf95 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewCommentComposer.test.ts +++ b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewCommentComposer.test.ts @@ -2,8 +2,8 @@ import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/vue'; -import { createComponentRenderer } from '@/__tests__/render'; -import { mockedStore } from '@/__tests__/utils'; +import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import { useReviewActivityStore } from '../reviewActivity.store'; import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue'; @@ -43,6 +43,32 @@ describe('WorkflowReviewCommentComposer', () => { expect(second.getByRole('textbox')).toHaveValue('half a thought'); }); + it('clears a sent draft when a tab switch unmounts its composer', async () => { + const pending = createDeferredPromise(); + store.postComment.mockReturnValueOnce(pending.promise); + store.draft = 'Sent comment'; + const first = renderComponent({ props: { canComment: true } }); + await userEvent.click(first.getByTestId('send-message-button')); + first.unmount(); + pending.resolve(true); + await waitFor(() => expect(store.draft).toBe('')); + const second = renderComponent({ props: { canComment: true } }); + expect(second.getByRole('textbox')).toHaveValue(''); + }); + + it('reports a failed post after a tab switch unmounts the composer', async () => { + const pending = createDeferredPromise(); + store.postComment.mockReturnValueOnce(pending.promise); + store.draft = 'Sent comment'; + const { getByTestId, unmount } = renderComponent({ props: { canComment: true } }); + await userEvent.click(getByTestId('send-message-button')); + unmount(); + pending.reject(new Error('timeout')); + await pending.promise.catch(() => {}); + expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Could not post comment'); + expect(store.draft).toBe('Sent comment'); + }); + it('disables the send button and the textarea when the viewer cannot comment', () => { const { getByTestId, getByRole } = renderComponent({ props: { canComment: false } }); diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewCommentComposer.vue b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewCommentComposer.vue similarity index 100% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewCommentComposer.vue rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewCommentComposer.vue diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDecisionPopover.test.ts b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewDecisionPopover.test.ts similarity index 92% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDecisionPopover.test.ts rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewDecisionPopover.test.ts index b69766782cc..bed241c2113 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDecisionPopover.test.ts +++ b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewDecisionPopover.test.ts @@ -1,8 +1,8 @@ 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 { createComponentRenderer, mockedStore, waitAllPromises } from '@n8n/frontend-test-utils'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import { useReviewActivityStore } from '../reviewActivity.store'; import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue'; @@ -92,6 +92,22 @@ describe('WorkflowReviewDecisionPopover', () => { expect(tooltipOf(requestChanges)).toHaveAttribute('data-disabled', 'true'); }); + it('reports a failed comment after the selected review changes', async () => { + const pending = createDeferredPromise(); + store.postComment.mockReturnValueOnce(pending.promise); + store.decisionNote = 'Sent comment'; + const { getByTestId, unmount } = renderComponent(); + await userEvent.click(getByTestId('workflow-review-decision-comment-button')); + unmount(); + store.decisionNote = 'New note'; + const error = new Error('timeout'); + pending.reject(error); + await waitAllPromises(); + + expect(showError).toHaveBeenCalledWith(error, 'Could not post comment'); + expect(store.decisionNote).toBe('New note'); + }); + 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], diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDecisionPopover.vue b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewDecisionPopover.vue similarity index 100% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDecisionPopover.vue rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewDecisionPopover.vue index 4b666016bb7..c02c045a0c2 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDecisionPopover.vue +++ b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewDecisionPopover.vue @@ -120,8 +120,8 @@ async function onComment() {
{ */ const showApprovedAndPublished = computed(() => { const review = detail.value; - if (!review || review.state !== 'closed' || review.decision !== 'approved') return false; + if (review?.state !== 'closed' || review.decision !== 'approved') return false; return ( review.workflows.length > 0 && diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewStatusDot.vue b/packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewStatusDot.vue similarity index 100% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewStatusDot.vue rename to packages/modules/inbox/frontend/src/reviews/components/WorkflowReviewStatusDot.vue diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/_activity-card.scss b/packages/modules/inbox/frontend/src/reviews/components/_activity-card.scss similarity index 100% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/_activity-card.scss rename to packages/modules/inbox/frontend/src/reviews/components/_activity-card.scss diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue b/packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue similarity index 100% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue rename to packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts b/packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts similarity index 97% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts rename to packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts index 0f1ba100301..61a5bc39c90 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +++ b/packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts @@ -1,6 +1,6 @@ import type { WorkflowReviewActivityEntry, WorkflowReviewActivityMessage } from '@n8n/api-types'; import { createTestingPinia } from '@pinia/testing'; -import { createComponentRenderer } from '@/__tests__/render'; +import { createComponentRenderer } from '@n8n/frontend-test-utils'; import WorkflowReviewActivityComment from './WorkflowReviewActivityComment.vue'; diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.vue b/packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue similarity index 91% rename from packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.vue rename to packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue index 1d085782c71..ece534c429a 100644 --- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.vue +++ b/packages/modules/inbox/frontend/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue @@ -1,9 +1,9 @@ + + + + diff --git a/packages/modules/inbox/frontend/stylelint.config.mjs b/packages/modules/inbox/frontend/stylelint.config.mjs new file mode 100644 index 00000000000..c78fcef84b6 --- /dev/null +++ b/packages/modules/inbox/frontend/stylelint.config.mjs @@ -0,0 +1,9 @@ +import { baseConfig } from '@n8n/stylelint-config/base'; + +export default { + ...baseConfig, + rules: { + ...baseConfig.rules, + '@n8n/css-var-naming': [true, { severity: 'error' }], + }, +}; diff --git a/packages/modules/inbox/frontend/tsconfig.json b/packages/modules/inbox/frontend/tsconfig.json new file mode 100644 index 00000000000..878dd5f1145 --- /dev/null +++ b/packages/modules/inbox/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@n8n/typescript-config/tsconfig.frontend-module.json", + "compilerOptions": { + // `rootDirs`, `types` and `include` cannot be inherited from the base: relative entries in + // them resolve against this file, so a copy in the base would point at the base's directory. + // (`paths` is the exception and does come from the base.) + "rootDirs": [".", "../../../frontend/@n8n/design-system/src"], + // The two `.d.ts` entries are ambient declarations this package never imports, so nothing + // pulls them into the program: `~icons/*` and `markdown-it-task-lists` for design-system's + // source, `window.BASE_PATH` for `@n8n/stores`'s. Consuming those packages from source is + // what makes them the consumer's problem — a built `dist` would have carried them. + "types": [ + "vite/client", + "vitest/globals", + "unplugin-icons/types/vue", + "../../../frontend/@n8n/design-system/src/shims-modules.d.ts", + "../../../frontend/@n8n/stores/src/shims.d.ts" + ] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/packages/modules/inbox/frontend/vite.config.ts b/packages/modules/inbox/frontend/vite.config.ts new file mode 100644 index 00000000000..b3fba0bcc5f --- /dev/null +++ b/packages/modules/inbox/frontend/vite.config.ts @@ -0,0 +1,52 @@ +import vue from '@vitejs/plugin-vue'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import icons from 'unplugin-icons/vite'; +import svgLoader from 'vite-svg-loader'; +import { defineConfig, mergeConfig } from 'vite'; +import { frontendAliases } from '@n8n/frontend-vite-config'; +import { vitestConfig } from '@n8n/vitest-config/frontend'; + +const packageDir = fileURLToPath(new URL('.', import.meta.url)); +const packagesDir = resolve(packageDir, '..', '..', '..'); + +export default mergeConfig( + defineConfig({ + // `@n8n/design-system`'s icon set reaches for two things Vite does not handle on its + // own: `~icons/lucide/*` virtual modules and `./custom/*.svg` single-file components. + // Consuming design-system from source makes both the consumer's problem — without + // `svgLoader` an `.svg` import returns a data-URI string, which Vue then renders as a + // tag name and jsdom rejects. Every module that renders a design-system component + // needs these two plugins. `src/__tests__/design-system-icons.test.ts` guards them. + plugins: [ + vue(), + // Off, so a test never reaches the network for a missing collection. The collection + // comes from the `@iconify/json` devDependency instead. + icons({ compiler: 'vue3', autoInstall: false }), + svgLoader({ + svgoConfig: { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + // The icons rely on their ids, and on a viewBox to stay scalable. + cleanupIds: false, + removeViewBox: false, + }, + }, + }, + ], + }, + }), + ], + resolve: { + // The same platform mapping the editor-ui dev server uses, so a test resolves + // `@n8n/stores/...` from source rather than from a stale `dist` — the two disagreeing is + // what put 1,111 specifiers on the wrong side of the src/dist line. Sibling modules are + // deliberately absent: nothing here should make a cross-module import resolve. + alias: frontendAliases(packagesDir), + }, + }), + vitestConfig, +); diff --git a/packages/quality/testing/playwright/pages/WorkflowReviewsPage.ts b/packages/quality/testing/playwright/pages/WorkflowReviewsPage.ts index 2af36560f48..5ab0123a6b5 100644 --- a/packages/quality/testing/playwright/pages/WorkflowReviewsPage.ts +++ b/packages/quality/testing/playwright/pages/WorkflowReviewsPage.ts @@ -2,15 +2,15 @@ import type { Locator } from '@playwright/test'; import { BasePage } from './BasePage'; -/** The review inbox at `/reviews`: request list, activity feed and decision popover. */ +/** Reviews in the shared Inbox, with their activity and decisions. */ export class WorkflowReviewsPage extends BasePage { async goto(): Promise { - await this.page.goto('/reviews'); - await this.page.getByTestId('workflow-review-requests-view').waitFor({ state: 'visible' }); + await this.page.goto('/inbox'); + await this.page.getByTestId('inbox-view').waitFor({ state: 'visible' }); } getRequestRow(title: string): Locator { - return this.page.getByTestId('workflow-review-request-row').filter({ hasText: title }); + return this.page.getByTestId('inbox-row').filter({ hasText: title }); } getSelectedRequestTitle(): Locator { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07669a26ffa..6995ad18728 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6636,6 +6636,9 @@ importers: '@n8n/frontend-constants': specifier: workspace:* version: link:../@n8n/frontend-constants + '@n8n/frontend-module-inbox': + specifier: workspace:* + version: link:../../modules/inbox/frontend '@n8n/frontend-module-insights': specifier: workspace:* version: link:../../modules/insights/frontend @@ -7036,6 +7039,124 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/modules/inbox/frontend: + dependencies: + '@n8n/api-types': + specifier: workspace:* + version: link:../../../@n8n/api-types + '@n8n/composables': + specifier: workspace:* + version: link:../../../frontend/@n8n/composables + '@n8n/design-system': + specifier: workspace:* + version: link:../../../frontend/@n8n/design-system + '@n8n/frontend-constants': + specifier: workspace:* + version: link:../../../frontend/@n8n/frontend-constants + '@n8n/frontend-module-sdk': + specifier: workspace:* + version: link:../../../frontend/@n8n/frontend-module-sdk + '@n8n/i18n': + specifier: workspace:* + version: link:../../../frontend/@n8n/i18n + '@n8n/rest-api-client': + specifier: workspace:* + version: link:../../../frontend/@n8n/rest-api-client + '@n8n/stores': + specifier: workspace:* + version: link:../../../frontend/@n8n/stores + '@vueuse/core': + specifier: catalog:frontend + version: 14.3.0(vue@3.5.26(typescript@6.0.2)) + lodash: + specifier: 4.18.1 + version: 4.18.1(patch_hash=81ead1b07fc8efda87bd2724dbd876ab49f4a0aa03f96c3770060b2ea47d9e0b) + n8n-workflow: + specifier: workspace:* + version: link:../../../workflow + pinia: + specifier: catalog:frontend + version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)) + vue: + specifier: catalog:frontend + version: 3.5.26(typescript@6.0.2) + vue-i18n: + specifier: catalog:frontend + version: 11.4.5(vue@3.5.26(typescript@6.0.2)) + vue-router: + specifier: catalog:frontend + version: 4.5.0(vue@3.5.26(typescript@6.0.2)) + devDependencies: + '@iconify/json': + specifier: 'catalog:' + version: 2.2.488 + '@n8n/eslint-config': + specifier: workspace:* + version: link:../../../@n8n/eslint-config + '@n8n/frontend-test-utils': + specifier: workspace:* + version: link:../../../frontend/@n8n/frontend-test-utils + '@n8n/frontend-vite-config': + specifier: workspace:* + version: link:../../../frontend/@n8n/frontend-vite-config + '@n8n/playwright-janitor': + specifier: workspace:* + version: link:../../../quality/testing/janitor + '@n8n/stylelint-config': + specifier: workspace:* + version: link:../../../@n8n/stylelint-config + '@n8n/typescript-config': + specifier: workspace:* + version: link:../../../@n8n/typescript-config + '@n8n/utils': + specifier: workspace:* + version: link:../../../@n8n/utils + '@n8n/vitest-config': + specifier: workspace:* + version: link:../../../@n8n/vitest-config + '@pinia/testing': + specifier: ^0.1.6 + version: 0.1.6(pinia@2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)))(vue@3.5.26(typescript@6.0.2)) + '@testing-library/jest-dom': + specifier: catalog:frontend + version: 6.9.1 + '@testing-library/user-event': + specifier: catalog:frontend + version: 14.6.1(@testing-library/dom@10.4.1) + '@testing-library/vue': + specifier: catalog:frontend + version: 8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@6.0.2)) + '@types/lodash': + specifier: 'catalog:' + version: 4.17.17 + '@vitejs/plugin-vue': + specifier: catalog:frontend + version: 6.0.8(vite@8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))(vue@3.5.26(typescript@6.0.2)) + eslint: + specifier: 'catalog:' + version: 9.29.0(jiti@2.6.1)(supports-color@8.1.1) + stylelint: + specifier: 'catalog:' + version: 16.23.0(supports-color@8.1.1)(typescript@6.0.2) + typescript: + specifier: 'catalog:' + version: 6.0.2 + unplugin-icons: + specifier: catalog:frontend + version: 23.0.1(@vue/compiler-sfc@3.5.26) + vite: + specifier: 'catalog:' + version: 8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3) + vite-svg-loader: + specifier: catalog:frontend + version: 5.1.0(vue@3.5.26(typescript@6.0.2)) + vitest: + specifier: 'catalog:' + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + vue-tsc: + specifier: ^2.2.8 + version: 2.2.8(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@6.0.2) + packages/modules/insights/backend: dependencies: '@n8n/api-types':