diff --git a/.changeset/record-approvals-panel.md b/.changeset/record-approvals-panel.md new file mode 100644 index 0000000000..a6b12125e9 --- /dev/null +++ b/.changeset/record-approvals-panel.md @@ -0,0 +1,7 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-detail': minor +'@object-ui/i18n': minor +--- + +The record detail page now shows a read-gated approval panel (#3461). A record in approval used to expose NOTHING about the running approval to anyone but the current pending approver — `useRecordApprovals` was consumed solely to inject the header Approve/Reject buttons, while the pending-approver list, decision progress, and the `sys_approval_action` timeline existed only in the Approval Center's drawer, a `setup`-app surface that business roles can't navigate to (and whose backing object is tenant-wide, so granting read there is over-broad). The submitter couldn't tell whom to nudge; the record's own audit history was no help either, since the engine mirrors business fields as `runAs:'system'` and decisions never enter record history. The new surface is an **Approvals tab** on the record page — a peer of Details/Related (same promotion Attachments got in objectstack#4358), emitted by `buildDefaultTabs` only when the record actually has requests, with a request-count badge and the label localizing through the tab strip's KNOWN_LABEL_DICT (审批). The tab wraps the new `record:approvals` node (`RecordApprovalsPanel`), visible to EVERY viewer who can read the record: current flow/step with the enriched flow-steps strip, server-computed decision progress (quorum tally, per-group 会签 ticks), the waiting-on chips with server-resolved names and group labels (never raw ids), one chronological action timeline merged across all of the record's requests (a multi-level flow opens one request per node), decision comments and attachments, and an inline remind button for the submitter (`viewer.is_submitter`, with an id-match fallback for older backends) that POSTs the existing `/approvals/requests/:id/remind`. The host threads its live `useRecordApprovals` read through the node so the tab and the header decision buttons never disagree; on authored pages the `record:approvals` renderer self-fetches, and an authored page that omits the node gets a bottom-of-page fallback append so the approval story is never lost to a custom layout. Copy reuses the Approval Center's `approvalsInbox.*` keys so the two surfaces can't drift; `useRecordApprovals` now exposes the full `requests` array plus `listApprovalActions` / `remindApprovalRequest`, and its `ApprovalRequestLite` carries the display enrichment (`process_label`, `step_label`, `flow_steps`, `viewer`, `round`) the single-read endpoint already sent. diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index f14ecfca62..01482f9866 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -31,6 +31,7 @@ The console opens at **http://localhost:5175** with MSW (Mock Service Worker) pr | **Studio Package Scope** | Studio home, metadata counts, quick-create links, and diagnostics follow the selected package. | | **Design in Studio** | Workspace admins get a top-bar entry inside a running app that opens its owning package on the Studio design surface. On an interface route — a dashboard, page, or report — it deep-links straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=:`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) it opens the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. | | **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | +| **Record Approvals Tab** | A record with approval requests grows an Approvals tab on its detail page (peer of Details/Related, with a request-count badge) — current step, decision progress, resolved "waiting on" approvers, the merged decision timeline, and a submitter remind button — visible to every viewer who can read the record, not just approvers. | | **Error Boundary** | Graceful error handling with a retry button. | ### Object design (Studio Data tab) diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index a59190326e..84d1b1eaad 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -557,6 +557,32 @@ via the action runner, regardless of the object's `editMode`: See [`content/docs/guide/record-edit-modes.md`](../../content/docs/guide/record-edit-modes.md) for a longer walkthrough. +## Record approval visibility (Approvals tab / `record:approvals`) + +When a record has approval requests, its detail page grows an **Approvals +tab** — a peer of Details/Related with a request-count badge (#3461): +which step the approval sits at (with the flow's step strip), the +server-computed decision progress (quorum tally, per-group 会签 ticks), the +**waiting-on** approvers resolved to display names (group approvers labeled +with their group), one chronological decision timeline merged across all of +the record's requests (comments and attachments included), and an inline +**Send reminder** button for the submitter. Records without requests carry +no tab at all. + +Visibility is gated by record READ access, not approver status — anyone who +can open the record sees where its approval stands, without a trip to the +Approval Center (a `setup`-app surface business roles typically cannot +reach). The tab wraps the schema-addressable `record:approvals` node +(`RecordApprovalsPanel`): on the synthesized default page the host threads +its live `useRecordApprovals` read through the node — the same read behind +the header's Approve/Reject buttons, so the two can never disagree — while +on authored pages the renderer self-fetches via RecordContext, and an +authored page that omits the node gets a bottom-of-page fallback append. +The timeline reads `GET /approvals/requests/:id/actions` per request; the +submitter's remind posts the existing `POST /approvals/requests/:id/remind` +(throttled server-side). Copy reuses the Approval Center's +`approvalsInbox.*` i18n keys so the two surfaces never drift. + ## User-scoped state (favorites, recent items) `` includes `FavoritesProvider` and `RecentItemsProvider` — diff --git a/packages/app-shell/src/hooks/useRecordApprovals.ts b/packages/app-shell/src/hooks/useRecordApprovals.ts index a29bedbea3..11fb889a71 100644 --- a/packages/app-shell/src/hooks/useRecordApprovals.ts +++ b/packages/app-shell/src/hooks/useRecordApprovals.ts @@ -79,6 +79,66 @@ export interface ApprovalRequestLite { pending_approver_groups?: Record | null; /** Display names for the ids in `pending_approvers` (id → name). */ pending_approver_names?: Record | null; + /** Human label of the originating flow (e.g. "Project Budget Approval"). */ + process_label?: string; + /** Human label of the pending approval step (e.g. "Manager Review"). */ + step_label?: string; + /** Display name of the submitter (`sys_user.name`), when resolvable. */ + submitter_name?: string; + /** Owning flow's approval steps for progress display (single reads only). */ + flow_steps?: Array<{ id: string; label: string; state: 'done' | 'current' | 'upcoming' }>; + /** + * Server-computed capability of the current viewer (framework#3310), attached + * by `getRequest`. `is_submitter` gates the record page's remind affordance + * the same way the Approval Center gates its submitter levers — the server + * resolved the identity, so the panel never re-derives it client-side. + */ + viewer?: { + can_act: boolean; + is_submitter: boolean; + can_override?: boolean; + }; + /** ADR-0044 revision round on this (run, node): absent/1 = first round. */ + round?: number; +} + +/** + * A file attached to a decision action. The server resolves the + * `sys_approval_action.attachments` file field into rich descriptors, so a + * consumer has the display name without any `sys_file` lookup; opening one + * still goes through the signed-URL storage route. + */ +export interface ApprovalActionAttachmentLite { + id: string; + name?: string; + url?: string; + mimeType?: string; + size?: number; +} + +/** + * One `sys_approval_action` row — a submit/approve/reject/… event on a + * request's thread, as `GET /approvals/requests/:id/actions` sends it. + * The approval timeline the Approval Center draws is made of these; the + * record page's approval panel reads the same rows (objectui#3461). + */ +export interface ApprovalActionLite { + id: string; + request_id: string; + step_index?: number | null; + step_name?: string | null; + actor_id?: string | null; + /** Display name of the actor, resolved server-side. */ + actor_name?: string; + action: 'submit' | 'approve' | 'reject' | 'recall' | string; + comment?: string | null; + attachments?: ApprovalActionAttachmentLite[] | null; + created_at?: string; + /** Structured reassign hand-off parties (framework#4365), reassign rows only. */ + reassign_from?: string; + reassign_to?: string; + reassign_from_name?: string; + reassign_to_name?: string; } /** @@ -117,6 +177,14 @@ export function recordLockedByApproval(request: ApprovalRequestLite | null | und interface UseRecordApprovalsResult { loading: boolean; available: boolean; + /** + * Every approval request on the record, newest first — one per approval + * node the flow has reached (and per ADR-0044 revision round), so a + * multi-level flow accumulates several. The record page's approval panel + * renders them all (objectui#3461); `pendingRequest` / `latestRequest` + * remain the derived single-row reads the header actions consume. + */ + requests: ApprovalRequestLite[]; pendingRequest: ApprovalRequestLite | null; latestRequest: ApprovalRequestLite | null; /** The current user is among the pending approvers and may record a decision. */ @@ -195,6 +263,33 @@ async function fetchProgressEnrichment( } } +/** + * Read a request's action thread (`sys_approval_action`) — who decided what, + * when, with which comment/attachments. Same rows the Approval Center's + * timeline draws; the record page's approval panel merges them across the + * record's requests (objectui#3461). Empty array on any shape mismatch. + */ +export async function listApprovalActions(requestId: string): Promise { + const out = await fetchJson<{ data: ApprovalActionLite[] }>( + `/approvals/requests/${encodeURIComponent(requestId)}/actions`, + ); + return Array.isArray(out?.data) ? out.data : []; +} + +/** + * Submitter nudge — notifies the pending approvers (throttled server-side, + * 429/THROTTLED when sent too recently). `notified` is how many were pinged. + */ +export async function remindApprovalRequest( + requestId: string, +): Promise<{ notified?: number }> { + const out = await fetchJson<{ notified?: number }>( + `/approvals/requests/${encodeURIComponent(requestId)}/remind`, + { method: 'POST', body: JSON.stringify({}) }, + ); + return out ?? {}; +} + export function useRecordApprovals( objectName: string | undefined, recordId: string | undefined, @@ -244,15 +339,17 @@ export function useRecordApprovals( [requests], ); - const latestRequest = useMemo(() => { - if (requests.length === 0) return null; - const sorted = [...requests].sort((a, b) => { - const at = a.submitted_at || a.completed_at || ''; - const bt = b.submitted_at || b.completed_at || ''; - return bt.localeCompare(at); - }); - return sorted[0] ?? null; - }, [requests]); + const sortedRequests = useMemo( + () => + [...requests].sort((a, b) => { + const at = a.submitted_at || a.completed_at || ''; + const bt = b.submitted_at || b.completed_at || ''; + return bt.localeCompare(at); + }), + [requests], + ); + + const latestRequest = sortedRequests[0] ?? null; const canDecide = !!pendingRequest && !!currentUserId && (pendingRequest.pending_approvers ?? []).includes(currentUserId); @@ -288,6 +385,7 @@ export function useRecordApprovals( return { loading, available, + requests: sortedRequests, pendingRequest, latestRequest, canDecide, diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 933e959226..ed72a151b7 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -239,6 +239,9 @@ import './console/diagnostics/CloudAiModelStatus'; // `record:attachments` — schema-addressable Attachments panel referenced by // synthesized record pages when `enable.files: true` (objectstack#4358). import './views/record-attachments-renderer'; +// `record:approvals` — schema-addressable approval panel referenced by +// synthesized record pages when the record has approval requests (#3461). +import './views/record-approvals-renderer'; // Phase 3c — generic metadata admin engine. Re-exported so plugins // can call `registerMetadataResource()` to override the per-type diff --git a/packages/app-shell/src/utils/pageSchemaIntrospect.ts b/packages/app-shell/src/utils/pageSchemaIntrospect.ts index c4c0383b27..d2f251f2b5 100644 --- a/packages/app-shell/src/utils/pageSchemaIntrospect.ts +++ b/packages/app-shell/src/utils/pageSchemaIntrospect.ts @@ -9,6 +9,7 @@ const DISCUSSION_TYPES = new Set(['record:discussion', 'record:chatter']); const ATTACHMENT_TYPES = new Set(['record:attachments']); +const APPROVAL_TYPES = new Set(['record:approvals']); /** * Walks a page schema tree and returns true if any node's `type` is in @@ -66,3 +67,13 @@ export function hasExplicitDiscussion(root: unknown): boolean { export function hasExplicitAttachments(root: unknown): boolean { return hasNodeOfType(root, ATTACHMENT_TYPES); } + +/** + * True when the page schema already places a `record:approvals` node — the + * synthesized default does whenever the record has approval requests + * (objectui#3461, an Approvals tab). The host must then skip its bottom + * fallback append. + */ +export function hasExplicitApprovals(root: unknown): boolean { + return hasNodeOfType(root, APPROVAL_TYPES); +} diff --git a/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx b/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx new file mode 100644 index 0000000000..70209a3f96 --- /dev/null +++ b/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx @@ -0,0 +1,267 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * RecordApprovalsPanel — the record page's read-only approval surface + * (objectui#3461). + * + * The bug this pins: a record in approval showed NOTHING about who it was + * waiting on to anyone but the pending approver — the pending-approver list + * and the decision timeline lived only in the Approval Center (a `setup`-app + * surface business roles can't reach). These tests assert the panel: + * + * 1. renders the waiting-on chips with server-RESOLVED names and 会签 group + * labels for a viewer who is NOT an approver — read-gated visibility is + * the whole point, so no `canDecide` anywhere near the render gate; + * 2. merges the action threads of EVERY request on the record (a + * multi-level flow opens one request per node) into one chronological + * timeline; + * 3. offers remind to the submitter only — `viewer.is_submitter` from the + * server, id-match fallback for older backends — and POSTs the + * documented endpoint; + * 4. renders no chrome at all for a record with no requests. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import type { ApprovalRequestLite } from '../hooks/useRecordApprovals'; + +vi.mock('@object-ui/auth', () => ({ + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + }), +})); + +import { toast } from 'sonner'; +import { RecordApprovalsPanel, approverChips, mergeActionTimelines } from './RecordApprovalsPanel'; + +/** Level-1 request — finalized; its thread carries the level-1 decision. */ +const REQ_L1: ApprovalRequestLite = { + id: 'req_l1', + process_name: 'flow:qif_review', + process_label: 'QIF Review', + object_name: 'qif_report', + record_id: 'rec_1', + status: 'approved', + submitter_id: 'u_submitter', + submitter_name: 'Zhou Ming', + submitted_at: '2026-08-01T08:00:00Z', + completed_at: '2026-08-02T08:00:00Z', +}; + +/** Level-2 request — pending 会签 across two groups, enriched single read. */ +const REQ_L2: ApprovalRequestLite = { + id: 'req_l2', + process_name: 'flow:qif_review', + process_label: 'QIF Review', + object_name: 'qif_report', + record_id: 'rec_1', + status: 'pending', + current_step: 'joint_signoff', + step_label: 'Joint Sign-off', + submitter_id: 'u_submitter', + submitter_name: 'Zhou Ming', + submitted_at: '2026-08-02T08:05:00Z', + pending_approvers: ['u_qa_head', 'u_prod_head', 'u_prod_head'], + pending_approver_names: { u_qa_head: 'Qian Hua', u_prod_head: 'Li Lei' }, + pending_approver_groups: { u_qa_head: ['quality'], u_prod_head: ['production'] }, + decision_progress: { + behavior: 'per_group', + got: 1, + need: 2, + groups: [ + { group: 'quality', got: 1, need: 1, satisfied: true }, + { group: 'production', got: 0, need: 1, satisfied: false }, + ], + }, + viewer: { can_act: false, is_submitter: false }, +}; + +const ACTIONS_BY_REQUEST: Record = { + req_l1: [ + { id: 'a1', request_id: 'req_l1', action: 'submit', actor_id: 'u_submitter', actor_name: 'Zhou Ming', created_at: '2026-08-01T08:00:00Z' }, + { id: 'a2', request_id: 'req_l1', action: 'approve', actor_id: 'u_dept_head', actor_name: 'Wang Fang', comment: 'Level 1 OK', created_at: '2026-08-02T08:00:00Z' }, + ], + req_l2: [ + { id: 'a3', request_id: 'req_l2', action: 'submit', actor_id: 'u_submitter', actor_name: 'Zhou Ming', created_at: '2026-08-02T08:05:00Z' }, + ], +}; + +/** Every request the stub answered, plus every POST body it captured. */ +let actionGets: string[]; +let remindPosts: string[]; + +function stubApi() { + actionGets = []; + remindPosts = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + if (init?.method === 'POST' && /\/approvals\/requests\/[^/]+\/remind$/.test(u)) { + remindPosts.push(u); + return { ok: true, json: async () => ({ notified: 2 }) } as any; + } + const m = u.match(/\/approvals\/requests\/([^/]+)\/actions$/); + if (m) { + actionGets.push(m[1]); + return { ok: true, json: async () => ({ data: ACTIONS_BY_REQUEST[m[1]] ?? [] }) } as any; + } + return { ok: false, status: 404, json: async () => ({}) } as any; + }), + ); +} + +function renderPanel(overrides: { + requests?: ApprovalRequestLite[]; + pendingRequest?: ApprovalRequestLite | null; + available?: boolean; + currentUserId?: string; +} = {}) { + const requests = overrides.requests ?? [REQ_L2, REQ_L1]; + return render( + r.status === 'pending') ?? null, + }} + currentUserId={overrides.currentUserId ?? 'u_viewer'} + />, + ); +} + +beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + stubApi(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('RecordApprovalsPanel — read-gated approval visibility (objectui#3461)', () => { + it('shows resolved waiting-on chips with 会签 groups to a non-approver viewer', async () => { + renderPanel(); // u_viewer holds no approver slot and did not submit + expect(await screen.findByTestId('record-approvals-panel')).toBeTruthy(); + expect(screen.getByText('Waiting on')).toBeTruthy(); + // Server-resolved display names — never the raw ids the API row carries. + expect(screen.getByText('Qian Hua')).toBeTruthy(); + expect(screen.getByText('Li Lei')).toBeTruthy(); + expect(screen.queryByText('u_qa_head')).toBeNull(); + // Group label + the collapsed ×2 count for the double-slot approver. + expect(screen.getByText('· production')).toBeTruthy(); + expect(screen.getByText('×2')).toBeTruthy(); + // The per-group branch of the tally copy. (The literal counts are + // interpolated by i18next, which this render intentionally runs without — + // the group badges below carry the numbers.) + expect(screen.getByText(/Sign-off progress/)).toBeTruthy(); + expect(screen.getByText(/quality 1\/1/)).toBeTruthy(); + expect(screen.getByText(/production 0\/1/)).toBeTruthy(); + // Header carries the flow label, current step, and pending status. + expect(screen.getByText(/QIF Review/)).toBeTruthy(); + expect(screen.getByText('Pending')).toBeTruthy(); + }); + + it('merges every request\'s action thread into one chronological timeline', async () => { + renderPanel(); + await waitFor(() => expect(screen.getByText('Wang Fang')).toBeTruthy()); + // Both requests were read — the level-1 decision is part of the story. + expect(actionGets.sort()).toEqual(['req_l1', 'req_l2']); + expect(screen.getByText('"Level 1 OK"')).toBeTruthy(); + // Chronological: level-1 submit first, level-2 submit last. + const items = screen.getAllByRole('listitem'); + expect(items[0].textContent).toContain('Zhou Ming'); + expect(items[1].textContent).toContain('Wang Fang'); + expect(items).toHaveLength(3); + }); + + it('offers remind to the submitter and POSTs the remind endpoint', async () => { + const pending = { + ...REQ_L2, + viewer: { can_act: false, is_submitter: true }, + }; + renderPanel({ requests: [pending, REQ_L1], pendingRequest: pending }); + const btn = await screen.findByRole('button', { name: /Send reminder/ }); + fireEvent.click(btn); + await waitFor(() => expect(remindPosts).toHaveLength(1)); + expect(remindPosts[0]).toMatch(/\/approvals\/requests\/req_l2\/remind$/); + await waitFor(() => expect((toast as any).success).toHaveBeenCalled()); + }); + + it('falls back to a submitter-id match when the backend sends no viewer', async () => { + const pending = { ...REQ_L2, viewer: undefined }; + renderPanel({ + requests: [pending, REQ_L1], + pendingRequest: pending, + currentUserId: 'u_submitter', + }); + expect(await screen.findByRole('button', { name: /Send reminder/ })).toBeTruthy(); + }); + + it('hides remind from a viewer who is not the submitter', async () => { + renderPanel(); // viewer.is_submitter false, id mismatch + await screen.findByTestId('record-approvals-panel'); + expect(screen.queryByRole('button', { name: /Send reminder/ })).toBeNull(); + }); + + it('renders nothing for a record with no approval requests', () => { + renderPanel({ requests: [], pendingRequest: null }); + expect(screen.queryByTestId('record-approvals-panel')).toBeNull(); + }); + + it('renders nothing when the approvals plugin is unavailable', () => { + renderPanel({ available: false }); + expect(screen.queryByTestId('record-approvals-panel')).toBeNull(); + }); +}); + +describe('approverChips', () => { + it('keeps the same person as separate chips per group, collapsing duplicates within one', () => { + const chips = approverChips({ + ...REQ_L2, + pending_approvers: ['u_a', 'u_a', 'u_a'], + pending_approver_names: { u_a: 'Ann' }, + pending_approver_groups: { u_a: ['finance'] }, + }); + expect(chips).toEqual([ + { label: 'Ann', group: 'finance', count: 3, title: 'u_a' }, + ]); + }); + + it('degrades to plain name chips when no group data exists', () => { + const chips = approverChips({ + ...REQ_L2, + pending_approvers: ['u_a', 'u_b'], + pending_approver_names: { u_a: 'Ann' }, + pending_approver_groups: null, + }); + expect(chips.map((c) => c.label)).toEqual(['Ann', 'u_b']); + expect(chips.every((c) => c.group === undefined)).toBe(true); + }); +}); + +describe('mergeActionTimelines', () => { + it('sorts merged threads chronologically regardless of per-thread order', () => { + const merged = mergeActionTimelines([ + [ + { id: 'b', request_id: 'r2', action: 'submit', created_at: '2026-08-02T00:00:00Z' }, + ], + [ + { id: 'c', request_id: 'r1', action: 'approve', created_at: '2026-08-03T00:00:00Z' }, + { id: 'a', request_id: 'r1', action: 'submit', created_at: '2026-08-01T00:00:00Z' }, + ], + ]); + expect(merged.map((a) => a.id)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/app-shell/src/views/RecordApprovalsPanel.tsx b/packages/app-shell/src/views/RecordApprovalsPanel.tsx new file mode 100644 index 0000000000..54d21a29e6 --- /dev/null +++ b/packages/app-shell/src/views/RecordApprovalsPanel.tsx @@ -0,0 +1,526 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { cn, Badge, Button } from '@object-ui/components'; +import { Stamp, Check, Circle, Paperclip, Loader2, Send } from 'lucide-react'; +import { toast } from 'sonner'; +import { createAuthenticatedFetch } from '@object-ui/auth'; +import { useObjectTranslation } from '@object-ui/react'; +import { + listApprovalActions, + remindApprovalRequest, + type ApprovalActionLite, + type ApprovalActionAttachmentLite, + type ApprovalRequestLite, +} from '../hooks/useRecordApprovals'; + +/** + * RecordApprovalsPanel — the record page's read-only approval surface + * (objectui#3461). + * + * Visibility is READ-gated, not approver-gated: anyone who can open the + * record sees which step its approval sits at, who it is waiting on, and + * what has been decided so far. Before this panel, the record page consumed + * the approvals API solely to decide whether the CURRENT user gets + * Approve/Reject buttons — the pending-approver list and the action thread + * existed only in the Approval Center, which business-app roles cannot even + * navigate to (its nav lives under the `setup` app), and granting them + * `sys_approval_request` list access would expose every tenant record's + * requests. "Who is my record waiting on" belongs on the record. + * + * Data comes from the SAME reads the header actions already use + * (`useRecordApprovals` — the hook's `requests` array), plus the per-request + * action thread (`GET /approvals/requests/:id/actions`, i.e. + * `sys_approval_action`) that the record's own audit history never sees: + * the engine writes business-field mirrors as `runAs:'system'`, so record + * history shows "System updated stage" and nothing else. Copy reuses the + * Approval Center's `approvalsInbox.*` i18n keys verbatim so the two + * surfaces cannot drift. + * + * A multi-level flow opens ONE request per approval node (and per ADR-0044 + * revision round), so the timeline here merges the action threads of every + * request on the record into a single chronological story — the "who + * approved level 1 and when" a per-request drawer can't show at a glance. + * + * Renders nothing when the approvals plugin is absent or the record has no + * requests — a record that never entered an approval gets no empty chrome. + */ + +export interface RecordApprovalsPanelProps { + /** + * The record's approval state, threaded from the host's ONE + * `useRecordApprovals()` call — the panel deliberately takes the hook's + * result instead of calling the hook itself so the record page keeps a + * single approvals read (the header decision buttons consume the same). + */ + approvals: { + available: boolean; + requests: ApprovalRequestLite[]; + pendingRequest: ApprovalRequestLite | null; + }; + /** Signed-in user id — remind fallback gate for pre-`viewer` backends. */ + currentUserId?: string | null; + className?: string; +} + +/** + * Semantic status colors — mirrors the Approval Center's STATUS_CLASSES + * (green = approved, amber = waiting, red = rejected, slate = recalled, + * violet = returned for revision). + */ +const STATUS_CLASSES: Record = { + pending: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400', + approved: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-400', + rejected: 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400', + recalled: 'border-border bg-muted text-muted-foreground', + returned: 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-400', +}; + +/** Timeline dot color per action kind — same palette as the Approval Center. */ +const ACTION_DOT: Record = { + approve: 'bg-emerald-500', + reject: 'bg-destructive', + submit: 'bg-blue-500', + reassign: 'bg-indigo-500', + remind: 'bg-amber-500', + request_info: 'bg-amber-500', + comment: 'bg-slate-400', + escalate: 'bg-red-500', + revise: 'bg-violet-500', + resubmit: 'bg-blue-500', +}; + +/** + * Render an actor/approver identifier in a friendly form (mirrors the + * Approval Center): emails as-is, `role:` labeled, opaque 16+ char ids + * middle-truncated — a raw UUID wall is exactly what #3461 complains about. + */ +export function formatIdentity(id: string | null | undefined): string { + if (!id) return '—'; + if (id.includes('@')) return id; + if (id.startsWith('role:')) return `Role: ${id.slice(5)}`; + if (id.length > 14) return `${id.slice(0, 6)}…${id.slice(-4)}`; + return id; +} + +/** `manager_review` → "Manager Review" (display fallback for legacy rows). */ +function prettifyMachineName(raw: string | null | undefined): string { + if (!raw) return '—'; + const base = String(raw).replace(/^flow:/, '').trim(); + return base.split(/[_\-\s]+/).filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') || '—'; +} + +/** + * Collapse the pending-approver chips, keyed by (name, group) so 会签 + * comprehension survives: the same person filling two different groups stays + * two labeled chips, a person filling one group twice collapses to one chip + * with a count. Mirrors the Approval Center's `approverChips` (#2762 P1-2 / + * objectui#2807); the tooltip keeps the underlying ids inspectable. + */ +export function approverChips( + r: ApprovalRequestLite, +): Array<{ label: string; group?: string; count: number; title: string }> { + const order: string[] = []; + const byKey = new Map(); + for (const a of r.pending_approvers || []) { + const label = r.pending_approver_names?.[a] || formatIdentity(a); + const gs = r.pending_approver_groups?.[a]; + const group = gs && gs.length ? gs.join(' / ') : undefined; + const key = group ? `${label} ${group}` : label; + const seen = byKey.get(key); + if (seen) { + seen.count += 1; + if (a && !seen.title.split(', ').includes(a)) seen.title += `, ${a}`; + } else { + byKey.set(key, { label, group, count: 1, title: a || label }); + order.push(key); + } + } + return order.map((k) => byKey.get(k)!); +} + +function formatDate(s: string | null | undefined): string { + if (!s) return '—'; + try { return new Date(s).toLocaleString(); } catch { return s; } +} + +/** + * Merge the per-request action threads into one chronological (oldest-first) + * timeline. Explicit sort — the panel must not depend on per-request server + * order once several requests' rows are concatenated. + */ +export function mergeActionTimelines(threads: ApprovalActionLite[][]): ApprovalActionLite[] { + return threads + .flat() + .sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || ''))); +} + +export const RecordApprovalsPanel: React.FC = ({ + approvals, + currentUserId, + className, +}) => { + const { t } = useObjectTranslation(); + const tr = React.useCallback( + (key: string, defaultValue: string, opts?: Record) => + String(t(`approvalsInbox.${key}`, { defaultValue, ...opts })), + [t], + ); + + const { available, requests, pendingRequest } = approvals; + // Newest-first from the hook; the pending row (at most one) leads the header. + const headline = pendingRequest ?? requests[0] ?? null; + + const [actions, setActions] = React.useState([]); + const [actionsLoading, setActionsLoading] = React.useState(false); + const [reminding, setReminding] = React.useState(false); + + // Reload key: the id set plus each request's decision state, as a stable + // string — so the timeline refetches when a decision lands (the host's + // hook refresh flips `status` or bumps the enriched `decision_progress` + // tally), but NOT on every refresh returning identical data in new arrays. + const requestIdsKey = requests + .map((r) => `${r.id} ${r.status} ${r.decision_progress?.got ?? ''}`) + .join(','); + + const reloadActions = React.useCallback(async () => { + const ids = requestIdsKey + ? requestIdsKey.split(',').map((k) => k.split(' ')[0]) + : []; + if (ids.length === 0) { + setActions([]); + return; + } + setActionsLoading(true); + try { + const threads = await Promise.all( + ids.map((id) => listApprovalActions(id).catch(() => [] as ApprovalActionLite[])), + ); + setActions(mergeActionTimelines(threads)); + } finally { + setActionsLoading(false); + } + }, [requestIdsKey]); + + React.useEffect(() => { + void reloadActions(); + }, [reloadActions]); + + /** + * Remind is the submitter's lever (server-authorized): prefer the + * server-resolved `viewer.is_submitter` from the enriched pending row and + * fall back to a plain id match for backends predating framework#3310. + */ + const isSubmitter = pendingRequest + ? pendingRequest.viewer?.is_submitter + ?? (!!currentUserId && pendingRequest.submitter_id === currentUserId) + : false; + + const handleRemind = React.useCallback(async () => { + if (!pendingRequest) return; + setReminding(true); + try { + const out = await remindApprovalRequest(pendingRequest.id); + toast.success(tr('remindSuccess', 'Reminder sent to {{count}} approver(s)', { + count: out?.notified ?? 0, + })); + await reloadActions(); + } catch (err: any) { + toast.error( + err?.code === 'THROTTLED' || err?.status === 429 + ? tr('remindThrottled', 'A reminder was sent recently — try again later.') + : err?.message || tr('loadFailed', 'Failed to load request'), + ); + } finally { + setReminding(false); + } + }, [pendingRequest, reloadActions, tr]); + + /** + * Open a timeline attachment via its short-lived signed URL — same + * user-gesture-preserving dance as the Approval Center (framework#3266 + * follow-up): open the tab synchronously, then point it at the URL. + */ + const authFetch = React.useMemo(() => createAuthenticatedFetch(), []); + const openAttachment = React.useCallback(async (att: ApprovalActionAttachmentLite) => { + const win = window.open('', '_blank', 'noopener'); + try { + const base = String((import.meta as any).env?.VITE_SERVER_URL || '').replace(/\/$/, ''); + const res = await authFetch(`${base}/api/v1/storage/files/${encodeURIComponent(att.id)}/url`); + if (!res.ok) throw new Error(`HTTP_${res.status}`); + const body = await res.json().catch(() => null); + const raw = body?.data?.url ?? body?.url; + if (!raw) throw new Error('NO_URL'); + const url = /^https?:\/\//i.test(raw) ? raw : `${base}${raw}`; + if (win) win.location.href = url; + else window.open(url, '_blank', 'noopener'); + } catch { + win?.close(); + toast.error(tr('attachmentOpenFailed', 'Could not open the attachment — please try again')); + } + }, [authFetch, tr]); + + // After all hooks: an unavailable plugin or a record with no requests + // renders no chrome at all. + if (!available || requests.length === 0 || !headline) return null; + + const statusLabel = (status: string): string => { + switch (status) { + case 'pending': return tr('statusPending', 'Pending'); + case 'approved': return tr('statusApproved', 'Approved'); + case 'rejected': return tr('statusRejected', 'Rejected'); + case 'recalled': return tr('statusRecalled', 'Recalled'); + case 'returned': return tr('statusReturned', 'Returned for revision'); + default: return status; + } + }; + + const actionText = (a: ApprovalActionLite): string => + a.action === 'submit' ? tr('actSubmit', 'Submitted') + : a.action === 'approve' ? tr('actApprove', 'Approved') + : a.action === 'reject' ? tr('actReject', 'Rejected') + : a.action === 'recall' ? tr('actRecall', 'Recalled') + : a.action === 'reassign' ? tr('actReassign', 'Reassigned') + : a.action === 'remind' ? tr('actRemind', 'Reminder sent') + : a.action === 'request_info' ? tr('actRequestInfo', 'Requested more info') + : a.action === 'comment' ? tr('actComment', 'Commented') + : a.action === 'escalate' ? tr('actEscalate', 'SLA escalated') + : a.action === 'revise' ? tr('actRevise', 'Sent back for revision') + : a.action === 'resubmit' ? tr('actResubmit', 'Resubmitted') + : a.action; + + const actorName = (a: ApprovalActionLite): string => + a.actor_id === 'system:sla' + ? tr('systemSlaActor', 'System (SLA)') + : a.actor_name ?? formatIdentity(a.actor_id); + + const dp = pendingRequest?.decision_progress; + const eligible = (pendingRequest?.pending_approvers || []).length; + const stepText = headline.status === 'pending' + ? (headline.step_label || (headline.current_step ? prettifyMachineName(headline.current_step) : null)) + : null; + + return ( +
+
+
+ + {t('detail.approvalsPanelTitle', { defaultValue: 'Approvals' })} + + {headline.process_label || prettifyMachineName(headline.process_name)} + {stepText ? ` · ${stepText}` : ''} + +
+
+ {(headline.round ?? 1) > 1 && ( + + {tr('roundChip', 'Round {{n}}', { n: headline.round })} + + )} + + {statusLabel(headline.status)} + +
+
+ +
+ {/* Flow-level step strip — where in the multi-level flow this record + sits; present on the enriched pending row only (single reads). */} + {(pendingRequest?.flow_steps?.length ?? 0) > 1 && ( +
+ {pendingRequest!.flow_steps!.map((s, i) => ( + + {i > 0 &&
} +
+ + {s.state === 'done' ? : i + 1} + + {s.label} +
+ + ))} +
+ )} + + {/* Aggregation progress — server-computed tally (quorum/unanimous + approvals, per-group 会签 groups), never re-derived client-side. */} + {dp && ( +
+
+ + {dp.behavior === 'per_group' + ? tr('progressGroups', 'Sign-off progress — {{got}} of {{need}} groups', { got: dp.got, need: dp.need }) + : tr('progressApprovals', 'Approvals — {{got}} of {{need}}', { got: dp.got, need: dp.need })} + + {dp.behavior !== 'per_group' && eligible > 0 && ( + + {tr('progressEligible', '{{count}} eligible approver(s)', { count: eligible })} + + )} +
+
+ {dp.need > 0 && dp.need <= 12 ? ( + Array.from({ length: dp.need }).map((_, i) => ( +
+ )) + ) : ( +
+
0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }} + /> +
+ )} +
+ {dp.groups && ( +
+ {dp.groups.map((g) => ( + + {g.satisfied ? : } + {g.group} {g.got}/{g.need} + + ))} +
+ )} +
+ )} + + {/* Who the pending step waits on — THE read this panel exists for: + server-resolved names (group approvers labeled with their group), + visible to every record reader, not just approvers. */} + {pendingRequest && (pendingRequest.pending_approvers || []).length > 0 && ( +
+
+ {tr('waitingOn', 'Waiting on')} +
+
+ {approverChips(pendingRequest).map((chip, i) => ( + + {chip.label} + {chip.group && · {chip.group}} + {chip.count > 1 && ×{chip.count}} + + ))} + {isSubmitter && ( + + )} +
+
+ )} + + {/* Merged action timeline — the approval story the record's own audit + history cannot tell (the engine mirrors fields as `runAs:'system'`, + and decisions live in sys_approval_action, not record history). */} +
+
+ {tr('history', 'Activity')} +
+ {actionsLoading && actions.length === 0 ? ( +
+ + {tr('loadingMore', 'Loading…')} +
+ ) : actions.length === 0 ? ( +
{tr('noActions', 'No actions yet.')}
+ ) : ( +
    + {actions.map((a) => ( +
  1. + +
    + {actionText(a)} + · + {actorName(a)} + {a.step_name && ( + · {prettifyMachineName(a.step_name)} + )} + + {formatDate(a.created_at)} + +
    + {a.action === 'reassign' && (a.reassign_from || a.reassign_to) && ( +
    + {tr('reassignFromTo', 'from {{from}} to {{to}}', { + from: a.reassign_from_name ?? formatIdentity(a.reassign_from), + to: a.reassign_to_name ?? formatIdentity(a.reassign_to), + })} +
    + )} + {a.comment && ( +
    "{a.comment}"
    + )} + {Array.isArray(a.attachments) && a.attachments.length > 0 && ( +
    + {a.attachments.map((att, i) => ( + + ))} +
    + )} +
  2. + ))} +
+ )} +
+
+
+ ); +}; + +export default RecordApprovalsPanel; diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index bf1518846f..908ee274c7 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -24,7 +24,7 @@ import { SkeletonDetail } from '../skeletons'; import { ManagedByBadge } from '../components/ManagedByBadge'; import { resolveEffectiveCrudAffordances } from '../utils/crudAffordances'; import { deriveRelatedLists } from '../utils/deriveRelatedLists'; -import { hasExplicitDiscussion, hasExplicitAttachments } from '../utils/pageSchemaIntrospect'; +import { hasExplicitDiscussion, hasExplicitAttachments, hasExplicitApprovals } from '../utils/pageSchemaIntrospect'; import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog'; import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog'; import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog'; @@ -45,6 +45,12 @@ import type { FeedItem } from '@object-ui/types'; import type { ActionDef, ActionParamDef } from '@object-ui/core'; import { useRecordApprovals, recordLockedByApproval } from '../hooks/useRecordApprovals'; import { RecordAttachmentsPanel } from './RecordAttachmentsPanel'; +import { RecordApprovalsPanel } from './RecordApprovalsPanel'; +// Side-effect registration of `record:approvals` — synthesized record pages +// reference the node whenever the record has approval requests (#3461), so +// the type must resolve wherever this view renders, not only under hosts +// that import the app-shell barrel. +import './record-approvals-renderer'; import { RecordPermissionAssignmentsRenderer } from './metadata-admin/RecordPermissionAssignmentsRenderer'; import { getRecordDisplayName } from '../utils'; import { parseAuditValue, collectAuditChanges, collectLookupIds, formatAuditValue } from '../utils/auditHistoryDisplay'; @@ -1663,6 +1669,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri headerActions?: ActionDef[]; related?: Array<{ title?: string; objectName: string; relationshipField: string; columns?: any[]; icon?: string; isPrimary?: boolean }>; history?: { entries: any[]; loading: boolean; unknownUserText?: string }; + approvals?: { count?: number; node?: Record }; }; } @@ -1854,13 +1861,33 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri unknownUserText: t('detail.unknownUser', { defaultValue: 'Unknown user' }), }, }), + // Approvals tab (#3461) — only when the record actually has requests, + // so approval-free records carry no dead tab. The node carries the + // LIVE hook result this view already holds (the same read behind the + // header decision buttons); like `history`, data flows through the + // synthesized schema, and the deps below rebuild the page when a + // decision changes the request set or its tally. + ...(approvals.available && approvals.requests.length > 0 && { + approvals: { + count: approvals.requests.length, + node: { + approvals: { + available: approvals.available, + requests: approvals.requests, + pendingRequest: approvals.pendingRequest, + }, + currentUserId: user?.id, + }, + }, + }), }; // eslint-disable-next-line react-hooks/exhaustive-deps // `approvals.pendingRequest` is in the deps for its `decision_output_defs`: // the header's decision params are synthesized from the pending node's // declaration, so they must be rebuilt when the request (and therefore the - // node) changes (objectui#2955). - }, [objectDef?.name, childRelations, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.canDecide, approvals.pendingRequest]); + // node) changes (objectui#2955). `approvals.requests` rides along for the + // Approvals tab payload (#3461). + }, [objectDef?.name, childRelations, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.canDecide, approvals.pendingRequest, approvals.requests, user?.id]); if (isLoading) { return ; @@ -2048,6 +2075,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri headerActions: synthParts.headerActions, related: synthParts.related, history: synthParts.history, + approvals: synthParts.approvals, // ADR-0085 removed the per-object `detail.*` presentation // toggles (show/hideReferenceRail, hideRelatedTab, relatedLayout) // — the synth defaults apply; per-page layout goes through an @@ -2056,6 +2084,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ...(assignedSlots ? { slots: assignedSlots } : {}), }); + // Same split as attachments, but introspected on `renderedPage` — the tree + // actually rendered — NOT `effectivePage` (#3461): the Approvals tab exists + // only in the synthParts-aware rebuild above (it depends on runtime request + // data), so the early `effectivePage` synth never contains it and checking + // that tree would double-render the panel (tab + bottom fallback). + const hasApprovalsNode = hasExplicitApprovals(renderedPage as any); + return (
{/* Shared cross-cutting chrome: lifecycle badge + presence avatars — @@ -2144,6 +2179,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri > + {/* Approval visibility fallback (objectui#3461) — synthesized + pages surface approvals as a TAB (`record:approvals` via + buildDefaultTabs); this bottom append fires only for + AUTHORED pages that don't place the node, so the read-gated + approval story is never lost to a custom layout. Renders + nothing when the record has no requests. */} + {pureRecordId && !hasApprovalsNode && ( + + )} {/* ADR-0056 P1b — user assignment lives in Setup (the pure model): the permission set's facets render read-only as summary + Studio deep-link, and admins add/remove users diff --git a/packages/app-shell/src/views/record-approvals-renderer.tsx b/packages/app-shell/src/views/record-approvals-renderer.tsx new file mode 100644 index 0000000000..c1df11c884 --- /dev/null +++ b/packages/app-shell/src/views/record-approvals-renderer.tsx @@ -0,0 +1,88 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `record:approvals` — schema-addressable wrapper around + * `RecordApprovalsPanel` (objectui#3461), mirroring how `record:attachments` + * wraps `RecordAttachmentsPanel`. + * + * Two data paths, host-first: + * + * 1. **Synthesized record page** — RecordDetailView threads its LIVE + * `useRecordApprovals` result through the node (`schema.approvals`). + * The same read drives the header's Approve/Reject buttons, so the tab + * content and the header can never disagree, and a decision made in the + * header re-renders the tab through the host's refresh — no second + * fetch, no drift. + * 2. **Authored page without the payload** — the renderer self-fetches via + * the RecordContext identity, so `record:approvals` stays usable as a + * plain schema node. The self-fetch hook is passed `undefined` names + * when the host payload exists, which keeps it inert (no request). + * + * Registered in app-shell rather than plugin-detail because the panel and + * its hook depend on `@object-ui/auth` (Bearer fetch), which plugin-detail + * deliberately does not pull in. The side-effect registration is imported + * from the app-shell barrel (`src/index.ts`). + */ + +import * as React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { useRecordContext } from '@object-ui/react'; +import { useAuth } from '@object-ui/auth'; +import { useRecordApprovals } from '../hooks/useRecordApprovals'; +import { RecordApprovalsPanel } from './RecordApprovalsPanel'; + +const splitDesigner = (props: Record) => { + const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; + return { designer: { 'data-obj-id': id, 'data-obj-type': type, style }, rest }; +}; + +export interface RecordApprovalsRendererProps { + schema?: Record; + className?: string; + [k: string]: any; +} + +export const RecordApprovalsRenderer: React.FC = ({ + schema, + className, + ...props +}) => { + const ctx = useRecordContext(); + const { user } = useAuth(); + const { designer } = splitDesigner(props); + + const hostApprovals = schema?.approvals; + // Authored-page fallback only: with a host payload the hook gets no + // identity and stays inert (its effect no-ops on undefined names). + const selfObjectName = hostApprovals ? undefined : ctx?.objectName; + const selfRecordId = hostApprovals + ? undefined + : ctx?.recordId != null ? String(ctx.recordId) : undefined; + const selfFetched = useRecordApprovals(selfObjectName, selfRecordId, user?.id); + + const approvals = hostApprovals ?? selfFetched; + const currentUserId = schema?.currentUserId ?? user?.id; + + return ( +
+ +
+ ); +}; + +ComponentRegistry.register('approvals', RecordApprovalsRenderer, { + namespace: 'record', + skipFallback: true, + category: 'record', + label: 'Approvals', + icon: 'Stamp', + inputs: [{ name: 'className', type: 'string', label: 'CSS Class' }], +}); + +export default RecordApprovalsRenderer; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 74de18a442..1a50fc0aee 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -864,6 +864,7 @@ const ar = { approvalProgress: "الموافقات — {{got}} من {{need}}", approvalProgressGroups: "التوقيعات — {{got}} من {{need}} مجموعات", approvalProgressLabel: "تقدّم الموافقة", + approvalsPanelTitle: "الموافقات", cancelApproval: "إلغاء الموافقة", cancelApprovalInFlight: "جارٍ الإلغاء…", cancelApprovalTooltip: "إلغاء طلب الموافقة المعلق لفتح قفل السجل", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index ec42345e12..17e83bc95b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -862,6 +862,7 @@ const de = { approvalProgress: "Genehmigungen — {{got}} von {{need}}", approvalProgressGroups: "Freigabe — {{got}} von {{need}} Gruppen", approvalProgressLabel: "Genehmigungsfortschritt", + approvalsPanelTitle: "Genehmigungen", cancelApproval: "Genehmigung zurückziehen", cancelApprovalInFlight: "Zurückziehen…", cancelApprovalTooltip: "Ausstehende Genehmigungsanfrage zurückziehen, um den Datensatz zu entsperren", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f980b5bae5..79fb73ea70 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -792,6 +792,7 @@ const en = { approvalProgress: 'Approvals — {{got}} of {{need}}', approvalProgressGroups: 'Sign-off — {{got}} of {{need}} groups', approvalProgressLabel: 'Approval progress', + approvalsPanelTitle: 'Approvals', cancelApproval: 'Recall approval', cancelApprovalInFlight: 'Recalling…', cancelApprovalTooltip: 'Recall the pending approval request to unlock this record', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 19cf4ebb5b..ae5df2da4b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -867,6 +867,7 @@ const es = { approvalProgress: "Aprobaciones — {{got}} de {{need}}", approvalProgressGroups: "Firmas — {{got}} de {{need}} grupos", approvalProgressLabel: "Progreso de la aprobación", + approvalsPanelTitle: "Aprobaciones", cancelApproval: "Cancelar aprobación", cancelApprovalInFlight: "Cancelando…", cancelApprovalTooltip: "Cancelar la solicitud de aprobación pendiente para desbloquear el registro", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3382a58ac8..e29a2e5418 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -864,6 +864,7 @@ const fr = { approvalProgress: "Approbations — {{got}} sur {{need}}", approvalProgressGroups: "Validation — {{got}} sur {{need}} groupes", approvalProgressLabel: "Progression de l'approbation", + approvalsPanelTitle: "Approbations", cancelApproval: "Annuler l'approbation", cancelApprovalInFlight: "Annulation…", cancelApprovalTooltip: "Annuler la demande d'approbation en attente pour déverrouiller l'enregistrement", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 52e6036070..445b916577 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -873,6 +873,7 @@ const ja = { approvalProgress: "承認 — {{need}} 件中 {{got}} 件", approvalProgressGroups: "合議 — {{need}} グループ中 {{got}} グループ", approvalProgressLabel: "承認の進捗", + approvalsPanelTitle: "承認", cancelApproval: "承認を取り消す", cancelApprovalInFlight: "取り消し中…", cancelApprovalTooltip: "承認待ちリクエストを取り消してレコードのロックを解除する", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4de3e20ae0..0f091e2b38 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -862,6 +862,7 @@ const ko = { approvalProgress: "승인 — {{need}}건 중 {{got}}건", approvalProgressGroups: "합의 — {{need}}개 그룹 중 {{got}}개", approvalProgressLabel: "승인 진행 상황", + approvalsPanelTitle: "승인", cancelApproval: "승인 취소", cancelApprovalInFlight: "취소 중…", cancelApprovalTooltip: "대기 중인 승인 요청을 취소하여 레코드 잠금 해제", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9cbea81ed4..5b7ec1ffec 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -864,6 +864,7 @@ const pt = { approvalProgress: "Aprovações — {{got}} de {{need}}", approvalProgressGroups: "Assinaturas — {{got}} de {{need}} grupos", approvalProgressLabel: "Progresso da aprovação", + approvalsPanelTitle: "Aprovações", cancelApproval: "Cancelar aprovação", cancelApprovalInFlight: "Cancelando…", cancelApprovalTooltip: "Cancelar a solicitação de aprovação pendente para desbloquear o registro", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index b531ad2351..8f3225aea3 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -875,6 +875,7 @@ const ru = { approvalProgress: "Согласования — {{got}} из {{need}}", approvalProgressGroups: "Подписи — {{got}} из {{need}} групп", approvalProgressLabel: "Ход согласования", + approvalsPanelTitle: "Согласования", cancelApproval: "Отменить согласование", cancelApprovalInFlight: "Отмена…", cancelApprovalTooltip: "Отмените ожидающий запрос на согласование, чтобы разблокировать запись", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index ab1636fd43..5fe4c0e119 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -771,6 +771,7 @@ const zh = { approvalProgress: '审批 — 已通过 {{got}} / 共需 {{need}}', approvalProgressGroups: '会签 — 已完成 {{got}} / 共 {{need}} 个组', approvalProgressLabel: '审批进度', + approvalsPanelTitle: '审批', cancelApproval: '撤回审批', cancelApprovalInFlight: '撤回中…', cancelApprovalTooltip: '撤回当前的待审批请求以解除记录锁定', diff --git a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index 8135bd7983..3bb5e775b1 100644 --- a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -781,6 +781,61 @@ describe('buildDefaultPageSchema', () => { expect(tabs.items[0].children[0].id).toBe('custom-details'); }); }); + + // objectui#3461 — records with approval requests get a peer Approvals tab + // wrapping `record:approvals`, fed the host's LIVE approvals read through + // the node payload so the tab and the header decision buttons can never + // disagree. No requests → no option passed → no dead tab. + describe('approvals tab (objectui#3461)', () => { + const nodePayload = { + approvals: { available: true, requests: [{ id: 'req_1' }], pendingRequest: null }, + currentUserId: 'u_1', + }; + + it('no approvals option → no record:approvals anywhere', () => { + const page = buildDefaultPageSchema(leadDef); + expect(JSON.stringify(page)).not.toContain('record:approvals'); + }); + + it('approvals option → tabs carry an Approvals tab wrapping record:approvals with the payload', () => { + const page = buildDefaultPageSchema(leadDef, { + approvals: { count: 2, node: nodePayload }, + }); + const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); + const tab = tabs.items.find((t: any) => t.value === 'approvals'); + expect(tab).toBeDefined(); + expect(tab.label).toBe('Approvals'); + expect(tab.count).toBe(2); + expect(tab.children).toEqual([{ type: 'record:approvals', ...nodePayload }]); + }); + + it('the Approvals tab sits after Related and before Attachments/Activity/History', () => { + const tabs = buildDefaultTabs({ ...leadDef, enable: { files: true } }, { + related: [{ objectName: 'task', relationshipField: 'lead_id' }], + showActivity: true, + history: { entries: [], loading: false }, + approvals: { node: nodePayload }, + }); + expect(tabs.items.map((t: any) => t.value)).toEqual([ + 'details', + 'related', + 'approvals', + 'attachments', + 'activity', + 'history', + ]); + }); + + it('a details slot override keeps the Approvals tab', () => { + const page = buildDefaultPageSchema(leadDef, { + approvals: { node: nodePayload }, + slots: { details: { type: 'div', id: 'custom-details' } }, + }); + const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); + expect(tabs.items.some((t: any) => t.value === 'approvals')).toBe(true); + expect(tabs.items[0].children[0].id).toBe('custom-details'); + }); + }); }); // ADR-0085 — top-level semantic roles (stageField / highlightFields). diff --git a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index bc5731946c..cf2f93c2dc 100644 --- a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -199,6 +199,17 @@ export interface BuildPageOptions { * flag because the data fetch logic lives in RecordDetailView. */ history?: { entries: any[]; loading?: boolean; emptyText?: string; unknownUserText?: string }; + /** + * When provided, emit an Approvals tab containing a `record:approvals` + * renderer (objectui#3461). `node` is spread verbatim onto the node — the + * host threads its LIVE approvals read (the same one driving the header's + * decision buttons) through it so the tab and the header can never + * disagree; the renderer self-fetches only when an authored page places + * the node without this payload. `count` badges the tab (number of + * approval requests on the record), same affordance as the auto-derived + * related-list counts. + */ + approvals?: { count?: number; node?: Record }; /** * Slot override map. When a slot is provided, the synthesizer emits * the override verbatim at the slot's position instead of computing @@ -563,7 +574,7 @@ export function buildDefaultDetails( export function buildDefaultTabs( def: ObjectDefLike | undefined, options: Pick = {}, ): any { const statusField = options.statusField ?? detectStatusField(def); @@ -616,6 +627,20 @@ export function buildDefaultTabs( } } } + // Approvals tab (objectui#3461) — emitted only when the host reports the + // record actually HAS approval requests, so approval-free records carry no + // dead tab. A peer of Details/Related for the same reason as Attachments + // below: footer placement buried the one thing a submitter opens the + // record to learn ("who is this waiting on"). The English label localizes + // through the tab strip's KNOWN_LABEL_DICT (→ 审批 etc.). + if (options.approvals) { + items.push({ + label: 'Approvals', + value: 'approvals', + ...(options.approvals.count != null ? { count: options.approvals.count } : {}), + children: [{ type: 'record:approvals', ...(options.approvals.node || {}) }], + }); + } // Attachments tab (objectstack#4358) — emitted for `enable.files: true` // objects so the panel is a peer of Details/Related instead of a footer // widget buried under the discussion feed. `PageTabsRenderer` derives the @@ -762,6 +787,7 @@ export function buildDefaultPageSchema( related: options.related, showActivity: options.showActivity, history: options.history, + approvals: options.approvals, highlightFields: options.highlightFields, statusField: options.statusField, hideRelatedTab, @@ -779,6 +805,7 @@ export function buildDefaultPageSchema( related: options.related, showActivity: options.showActivity, history: options.history, + approvals: options.approvals, highlightFields: options.highlightFields, statusField: options.statusField, hideRelatedTab,