diff --git a/.changeset/approvals-nav-points-at-inbox.md b/.changeset/approvals-nav-points-at-inbox.md new file mode 100644 index 0000000000..082ad852fd --- /dev/null +++ b/.changeset/approvals-nav-points-at-inbox.md @@ -0,0 +1,31 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/plugin-approvals': minor +--- + +Point the account app's **Approvals** navigation entry at the Approvals Inbox component, and contribute an **Approvals Inbox** entry to Setup (#7234). + +The entry point has not moved — the account menu still shows **Approvals** with the same +label and icon in every locale. Its destination has. It used to open the raw +`sys_approval_request` grid, which is an admin/diagnostic view of the engine's own table +and cannot show an approver a single decision button: every action on that object is gated +on `record.viewer.can_act || record.viewer.can_override`, and the `viewer` block is +attached only by the approvals REST path, never by the generic data API the object route +reads. The result was a correct-looking list of rows nobody could act on. The entry is now +`{ type: 'component', componentRef: 'approvals:inbox' }`, so it opens the full inbox — +decision actions, business vocabulary, node progress and the request drawer. + +- **Account app**: `nav_account_approvals` becomes a component entry gated by + `requiresService: 'approvals'`, so it disappears where `plugin-approvals` is not + installed (the previous `requiresObject` gate does not apply to a component entry). +- **Setup**: `plugin-approvals` contributes a new **Approvals Inbox** entry at the top of + **Setup → Approvals**, above the three raw tables, which stay exactly as they were — + admin-gated by `manage_platform_settings` and now unambiguously the diagnostic surface. + Labels ship in all four locales (zh-CN 审批中心). +- `sys_approval_request` is no longer surfaced raw to end users anywhere. +- **Docs**: the approver's queue is documented as the Approvals Inbox, with a snippet for + mounting it in any business app — one navigation entry naming the component-registry key + `approvals:inbox`, never a console path. + +Reaching the inbox end to end in the browser additionally requires the console pin bump, +tracked separately. diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index a09a379eb6..6f4dcfe681 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -314,9 +314,24 @@ and nothing can move it, so the run parks forever. ### The approver finds it in their queue -Requests land in **Setup → Approvals → Requests**, whose `my_pending` view -filters to `status = pending` and `pending_approvers` containing the current -user. Programmatically: +The approver's surface is the **Approvals Inbox** — in the stock console, the +**Approvals** entry in the account menu. It lists what is waiting on the current +user (`status = pending`, `pending_approvers` containing them) and it is the only +surface that carries the **decision actions**: approve, reject, return, reassign, +request info, delegate, remind, override. + + +**Why the raw `sys_approval_request` table is not that surface.** Every decision +action on the object is gated on `record.viewer.can_act || +record.viewer.can_override`, and the `viewer` block is computed and attached only +by the approvals REST path (`/api/v1/approvals/*`) — never by the generic data +API a plain object view reads. Browse the table directly and you get a correct, +completely inert list: the rows are there, the buttons are not. The table stays +available under **Setup → Approvals → Requests** for admins and diagnostics, +which is what it is good for. + + +Programmatically the same queue is: ```bash curl -b cookies.txt \ @@ -353,6 +368,45 @@ service and drive `remind()`. Reminder, escalation, return, and reassignment *do* publish notification topics — the initial request doesn't. +### Mount the approvals inbox in your app + +Approvals are cross-cutting: people approve expenses inside the expenses app, +not by leaving for a platform app. Add one navigation entry to any app you ship +and that app gets the full inbox: + +```ts +// my-app.app.ts +navigation: [ + // … + { + id: 'nav_approvals', + type: 'component', + label: 'Approvals', + componentRef: 'approvals:inbox', + icon: 'check-circle', + // Hide the entry where the approvals plugin is not installed. Enforced + // server-side: a gated-off entry never reaches the browser. + requiresService: 'approvals', + }, +], +``` + +`componentRef` names a **component-registry key**, not a URL. The console owns +the route it resolves to, so `approvals:inbox` keeps working if that route ever +changes — never hand-write a console path into app metadata. + +The inbox is per-user, not per-app: it shows the signed-in user everything +waiting on them across every object, because "what is waiting on me" is a +property of the person and not of the app they happen to have open. Filtering it +down to one app's objects is not an option today. + + +The platform's own **Account** app mounts it exactly this way, and +**Setup → Approvals → Approvals Inbox** mounts the same component for admins +above the raw `sys_approval_request` / `sys_approval_action` / +`sys_approval_delegation` tables. + + ### The decision ```bash diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index 16eadb95b4..03f582286e 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -52,7 +52,7 @@ anchors are: | **Apps** (`group_apps`) | Packages — `platform-objects`; marketplace — `cloud-connection` (capability plugin) | | **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` | | **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | -| **Approvals** (`group_approvals`) | `plugin-approvals` | +| **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` | | **Configuration** (`group_configuration`) | All Settings · Localization · Company · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | | **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` | | **Integrations** (`group_integrations`) | `plugin-webhooks` | diff --git a/packages/platform-objects/src/apps/account-approvals-nav.test.ts b/packages/platform-objects/src/apps/account-approvals-nav.test.ts new file mode 100644 index 0000000000..93c9618281 --- /dev/null +++ b/packages/platform-objects/src/apps/account-approvals-nav.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The account app's Approvals entry points at the Approvals Inbox COMPONENT, +// not at the raw `sys_approval_request` grid (#7234, entry C of #7213). +// +// Why this needs a pin rather than a code comment: the two shapes are one +// keyword apart and both parse. `type: 'object'` renders a perfectly healthy +// list of the engine's own table — which is exactly the defect. Every one of +// `sys_approval_request`'s eight decision actions is gated on +// `record.viewer.can_act || record.viewer.can_override`, and the `viewer` block +// is attached ONLY by the approvals REST path (`approval-service.ts`, +// `attachViewers()`), never by the generic data API the object route uses. So +// the object destination is a read-only wall of rows an approver cannot act on, +// and it LOOKS fine. A regression back to it would show up in no other test. +// +// The gate half is verified where it is implemented, not re-implemented here: +// `packages/rest/src/meta-app-area-nav-gate.test.ts` pins that `filterNav` +// strips a `type: 'component'` item whose `requiresService` names an absent +// service — variant-agnostic, which is what makes `requiresService` usable on a +// component item at all. +import { describe, it, expect } from 'vitest'; +import { AppSchema } from '@objectstack/spec/ui'; + +import { ACCOUNT_APP } from './account.app.js'; + +type NavItem = { + id?: string; + type?: string; + label?: string; + objectName?: string; + viewName?: string; + componentRef?: string; + requiresObject?: string; + requiresService?: string; + children?: NavItem[]; +}; + +/** Every nav item in the account app, depth-first. */ +const allNavItems = (): NavItem[] => { + const out: NavItem[] = []; + const walk = (items: NavItem[] = []) => { + for (const item of items) { + if (!item) continue; + out.push(item); + if (Array.isArray(item.children)) walk(item.children); + } + }; + walk((ACCOUNT_APP.navigation ?? []) as NavItem[]); + return out; +}; + +const approvalsEntry = (): NavItem => { + const item = allNavItems().find((i) => i.id === 'nav_account_approvals'); + expect(item, 'the account app lost its nav_account_approvals entry').toBeDefined(); + return item as NavItem; +}; + +describe("the account app's Approvals entry targets the inbox component (#7234)", () => { + it('routes to the `approvals:inbox` registry key', () => { + expect(approvalsEntry()).toMatchObject({ + type: 'component', + componentRef: 'approvals:inbox', + }); + }); + + it('addresses a registry KEY, never a console path', () => { + // objectui#2763's boundary: nav metadata names a component-registry key and + // the console owns the URL it resolves to (`approvals:inbox` → + // `/apps/{app}/component/approvals/inbox`, objectui#4071). A `/`-shaped ref + // here would be this package reaching across that line and would silently + // survive every schema check — `componentRef` is a free string, and + // `validate-nav-target-refs` deliberately does not resolve component refs + // (an unregistered ref is a NON-rule there: the registry is not visible to + // the linter). + const ref = approvalsEntry().componentRef ?? ''; + expect(ref).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/); + expect(ref).not.toContain('/'); + }); + + it('no longer routes to the raw sys_approval_request table', () => { + const entry = approvalsEntry(); + expect(entry.objectName).toBeUndefined(); + expect(entry.viewName).toBeUndefined(); + // `requiresObject` is the object-route gate; a component item has no object + // route to gate, so leaving it behind would be a dead key that reads as one. + expect(entry.requiresObject).toBeUndefined(); + }); + + it('gates on the approvals SERVICE, which is what backs the inbox', () => { + // `plugin-approvals` does `ctx.registerService('approvals', …)` in start(). + // Without a gate the entry would render a dead component route on any + // deployment that does not install the plugin — the "dead-quiet entry" + // failure. `requiresService` is stripped server-side by `filterAppForUser` + // (ADR-0057 D10), so a gated-off entry never reaches the browser. + expect(approvalsEntry().requiresService).toBe('approvals'); + }); + + it('keeps its label and icon — the entry point does not move', () => { + // Only the DESTINATION changes. The label is load-bearing beyond this file: + // `plugin-approvals`' approval-status-vocabulary.test.ts ties the + // `my_pending` view label to this entry's per-locale label (#7232/#7271), so + // rewording it here turns that cross-package parity case red. + expect(approvalsEntry()).toMatchObject({ label: 'Approvals', icon: 'check-circle' }); + }); + + it('surfaces sys_approval_request raw NOWHERE in the account app', () => { + // The acceptance criterion in its own words: the engine-owned table is an + // admin/diagnostic surface reachable only under Setup, behind + // `group_approvals`' `manage_platform_settings` gate. The account app + // deliberately declares no `requiredPermissions` (every authenticated user + // needs it for sessions / API keys), so anything it lists is listed for + // everyone — which is why this is asserted over the whole tree and not just + // over the one entry above. + const raw = allNavItems().filter((i) => i.objectName === 'sys_approval_request'); + expect(raw.map((i) => i.id)).toEqual([]); + }); + + it('the whole app still satisfies AppSchema with the component item', () => { + expect(() => AppSchema.parse(ACCOUNT_APP)).not.toThrow(); + }); +}); diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index 80c8448d38..ee2c38c27f 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -98,13 +98,35 @@ export const ACCOUNT_APP: App = { requiresObject: 'sys_inbox_message', }, { + // #7234 — the DESTINATION is the Approvals Inbox component, not the + // raw `sys_approval_request` grid. The engine-owned table has no + // decision actions for an end user: all eight are gated on + // `record.viewer.can_act || record.viewer.can_override`, and the + // `viewer` block is attached ONLY on the approvals REST path + // (`approval-service.ts`, `attachViewers()`), never on the generic + // data API. So the object route could only ever render a read-only + // list of rows an approver cannot act on. The inbox component calls + // the approvals REST path and therefore carries the decision + // actions, business vocabulary, node progress and drawer. + // + // The ref is the component-registry KEY, never a console path — that + // indirection is the whole contract (objectui#2763; the key is + // registered by objectui#4071). id: 'nav_account_approvals', - type: 'object', + type: 'component', label: 'Approvals', - objectName: 'sys_approval_request', - viewName: 'my_pending', + componentRef: 'approvals:inbox', icon: 'check-circle', - requiresObject: 'sys_approval_request', + // `requiresObject` cannot gate a component item — it names the + // object the entry ROUTES to, and this one routes to a component. + // The capability that must be present is the approvals SERVICE + // (`ctx.registerService('approvals', …)` in plugin-approvals), which + // is also what backs the REST path the inbox reads. Same shape as + // `nav_account_memberships` below, and enforced server-side by + // `filterAppForUser`/`filterNav` (ADR-0057 D10) — without it the + // entry would render a dead component route wherever + // plugin-approvals is not installed. + requiresService: 'approvals', }, { id: 'nav_account_memberships', diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 8e2c56d6bb..727a749b6a 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -98,7 +98,9 @@ export const en: TranslationData = { nav_api_keys: { label: 'API Keys' }, nav_connect_agent: { label: 'Connect an Agent' }, - // Approvals + // Approvals. `nav_approvals_inbox` is the working surface (#7234); the + // three below it are the engine's raw tables, kept as the admin view. + nav_approvals_inbox: { label: 'Approvals Inbox' }, nav_approval_requests: { label: 'Requests' }, nav_approval_actions: { label: 'Action History' }, nav_approval_delegations: { label: 'Delegations (OOO)' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index bd4891204e..884f9429b7 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -68,6 +68,7 @@ export const esES: TranslationData = { nav_api_keys: { label: 'Claves API' }, nav_connect_agent: { label: 'Conectar un agente' }, + nav_approvals_inbox: { label: 'Centro de aprobaciones' }, nav_approval_requests: { label: 'Solicitudes' }, nav_approval_actions: { label: 'Historial de Acciones' }, nav_approval_delegations: { label: 'Delegaciones (ausencia)' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index a894621e62..103fc727c0 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -68,6 +68,7 @@ export const jaJP: TranslationData = { nav_api_keys: { label: 'API キー' }, nav_connect_agent: { label: 'エージェントを接続' }, + nav_approvals_inbox: { label: '承認センター' }, nav_approval_requests: { label: 'リクエスト' }, nav_approval_actions: { label: 'アクション履歴' }, nav_approval_delegations: { label: '委任 (不在時)' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index f44e758ad2..5a5f914c0d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -71,6 +71,9 @@ export const zhCN: TranslationData = { nav_api_keys: { label: 'API 密钥' }, nav_connect_agent: { label: '连接智能体' }, + // 审批中心 = the Approvals Inbox component (#7234) — the surface with + // decision actions. The three below are the engine's raw tables. + nav_approvals_inbox: { label: '审批中心' }, nav_approval_requests: { label: '审批申请' }, nav_approval_actions: { label: '审批历史' }, // `审批委派` matches sys_approval_delegation's object label; `(外出)` diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 72c0f1c0e0..4d66ab9277 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -82,12 +82,23 @@ export class ApprovalsServicePlugin implements Plugin { // ADR-0029 D7 — contribute the Approvals entries into the Setup app's // `group_approvals` slot. This plugin owns these objects (K2.b), so it // ships their menu too; when the plugin isn't installed the slot is empty. + // + // #7234 — order inside the slot is ARRAY ORDER: `applyNavContributions` + // does `group.children.push(...c.items)` per contribution (sorted by + // `priority`), so the inbox being first in this array is what puts it + // above the raw tables. Do not reorder without meaning to. + // + // The inbox entry is the working surface (decision actions, node + // progress, drawer); the three object entries below stay as the + // admin/diagnostic view of the engine's own tables — reachable only here, + // behind `group_approvals`' `manage_platform_settings` gate. navigationContributions: [ { app: 'setup', group: 'group_approvals', priority: 100, items: [ + { id: 'nav_approvals_inbox', type: 'component', label: 'Approvals Inbox', componentRef: 'approvals:inbox', icon: 'list-checks' }, { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' }, { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' }, { id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' }, diff --git a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts index 1608e14b92..99e6e38fd2 100644 --- a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts +++ b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts @@ -37,13 +37,46 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', () expect(manifest.navigationContributions).toHaveLength(1); const contribution = manifest.navigationContributions[0]; expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' }); - expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([ + + // ORDER IS THE ASSERTION, not a by-product (#7234). `applyNavContributions` + // appends `c.items` into the group verbatim, so this array's order IS the + // rendered order — the inbox must come first. A set-shaped assertion (the + // `.sort()` this case used to open with) cannot see that, and putting the + // working surface below three raw tables is most of the defect #7213 + // reported. + expect(contribution.items.map((i: any) => i.id)).toEqual([ + 'nav_approvals_inbox', + 'nav_approval_requests', + 'nav_approval_actions', + 'nav_approval_delegations', + ]); + + // The inbox entry addresses the component REGISTRY KEY, never a console + // path (objectui#2763) — the console resolves `approvals:inbox` to + // `component/approvals/inbox` on its side (objectui#4071). + const [inbox, ...rawTables] = contribution.items as any[]; + expect(inbox).toMatchObject({ + id: 'nav_approvals_inbox', + type: 'component', + componentRef: 'approvals:inbox', + }); + // It carries no object gate: `requiresObject` names the object an entry + // routes to, and this one routes to a component. It needs none — a + // navigation CONTRIBUTION only exists while its plugin is installed, which + // is the same condition that makes the inbox's REST path answer. + expect(inbox.objectName).toBeUndefined(); + expect(inbox.requiresObject).toBeUndefined(); + + // The raw engine tables stay, unchanged, as the admin/diagnostic view. + expect(rawTables.map((i: any) => i.objectName)).toEqual([ + 'sys_approval_request', 'sys_approval_action', 'sys_approval_delegation', - 'sys_approval_request', ]); - // Each entry is gated so the slot stays empty when the plugin is absent. - for (const item of contribution.items) { + // Each object entry is gated so the slot degrades cleanly when an object is + // not registered. + for (const item of rawTables) { + expect(item.type).toBe('object'); expect(item.requiresObject).toBe(item.objectName); } });