Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/record-approvals-panel.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<type>:<name>`, 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)
Expand Down
26 changes: 26 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

`<ConsoleShell>` includes `FavoritesProvider` and `RecentItemsProvider` —
Expand Down
116 changes: 107 additions & 9 deletions packages/app-shell/src/hooks/useRecordApprovals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,66 @@ export interface ApprovalRequestLite {
pending_approver_groups?: Record<string, string[]> | null;
/** Display names for the ids in `pending_approvers` (id → name). */
pending_approver_names?: Record<string, string> | 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;
}

/**
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<ApprovalActionLite[]> {
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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -288,6 +385,7 @@ export function useRecordApprovals(
return {
loading,
available,
requests: sortedRequests,
pendingRequest,
latestRequest,
canDecide,
Expand Down
3 changes: 3 additions & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/app-shell/src/utils/pageSchemaIntrospect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Loading
Loading