Repository navigation
Commit 5495331
Fixes #22473
Clause-②: no
## What this does
`@objectstack/plugin-approvals` now serves `sys_approval_request`'s
record page as metadata, and it declares the request thread's reply as
an action. This is the backend half that the maintainer's ruling 乙 on
objectstack-ai/objectui#12045 (`6079807016`) assigns here:
> the request page is a slotted page in objectstack's plugin-approvals,
registered through the manifest's `pages`; objectui registers the
renderers and mounts `RecordDetailView`
The consumer of both halves is the console's approval request page,
built on objectui#12045. Its page-mount step waits on this PR. This PR
does not close that card.
| File (under `packages/plugins/plugin-approvals/`) | Change |
| :--- | :--- |
| `src/sys-approval-request.page.ts` (new) |
`SysApprovalRequestDetailPage`: a slotted page, default for every
request |
| `src/approvals-plugin.ts` | the manifest registers `pages:
[SysApprovalRequestDetailPage]` |
| `src/sys-approval-request.object.ts` | adds a ninth declared action,
`approval_comment` |
| `src/translations/pages.ts` (new), `src/translations/index.ts` |
`pages.sys_approval_request_detail.label` in en / zh-CN / ja-JP / es-ES,
with recorded source digests |
| `scripts/i18n-extract.config.ts` | declares the page and the `pages`
baseline, so the coverage ratchet counts the label |
| `src/translations/*.objects.generated.ts` | regenerated for
`approval_comment` (`node scripts/check-i18n-bundles.mjs --write`); the
zh-CN / ja-JP / es-ES leaves are translated by hand |
| tests, `.changeset/22473-approval-request-page.md` (`minor`) | see
Evidence below |
The page is module-internal: `index.ts` exports nothing new, and
`exports` has only `.`. So `Clause-②` is `no`. Nothing changes in
`packages/spec` or `platform-objects`. Outside the plugin, this PR
touches one test
(`packages/qa/dogfood/test/approval-override-composite-pin.dogfood.test.ts`,
which pins this object's served actions; patch round 1 at `69fef50a2`)
and one docs paragraph (`content/docs/automation/approvals.mdx`, the
list of declared actions; cross-lane declaration on #6023).
## The page's slot map, as served
This was read back from `GET
/api/v1/meta/page/sys_approval_request_detail` on the showcase dev
server (`pnpm dev -- --fresh`, HEAD `8f30e97c3`), signed in as the
seeded admin:
```json
{
"name": "sys_approval_request_detail",
"label": "Approval Request",
"type": "record",
"object": "sys_approval_request",
"kind": "slotted",
"isDefault": true,
"_packageId": "com.objectstack.service.approvals",
"slots": {
"actions": [
{
"type": "record:approval_decision"
}
],
"highlights": {
"type": "record:highlights",
"properties": {
"fields": [
"process_name",
"object_name",
"status",
"current_step",
"submitter_id",
"updated_at"
]
}
},
"tabs": {
"type": "page:tabs",
"properties": {
"tabStyle": "line",
"position": "top",
"items": [
{
"label": {
"en": "Details",
"zh-CN": "详情",
"ja-JP": "詳細",
"es-ES": "Detalles"
},
"icon": "file-text",
"children": [
{
"type": "record:details"
}
]
},
{
"label": {
"en": "Timeline",
"zh-CN": "时间线",
"ja-JP": "タイムライン",
"es-ES": "Cronología"
},
"icon": "history",
"children": [
{
"type": "record:related_list",
"properties": {
"objectName": "sys_approval_action",
"relationshipField": "request_id",
"columns": [
"created_at",
"action",
"actor_id",
"step_name",
"comment",
"attachments"
],
"sort": [
{
"field": "created_at",
"order": "desc"
}
],
"limit": 50,
"showViewAll": true,
"title": {
"en": "Timeline",
"zh-CN": "时间线",
"ja-JP": "タイムライン",
"es-ES": "Cronología"
}
}
}
]
}
]
}
},
"discussion": []
}
}
```
- **`actions`** holds one `record:approval_decision` node with no
properties. The type was declared on `main` at `7731d7018f` with an
empty, strict props row.
- **`highlights`** is the object's `highlightFields` without
`record_id`. This is the seat's choice on the ui seat's design input
`6081889955`. The object's own `highlightFields`
(`sys-approval-request.object.ts:57`) is unchanged. So `record_id` stays
in the details grid, where the console draws the target-record card for
the `record_id` / `object_name` pointer pair.
- **`tabs`** has two tabs: Details (`record:details`) and Timeline
(`sys_approval_action` as a related list on `request_id`, newest first).
The page authors `tabs`, not `details`, because the console's builder
(objectui `buildDefaultPageSchema`) takes `tabs` over `details` when
both are present. The details body therefore lives in the first tab.
- **`discussion: []`**: there is no `record:discussion` node. The
thread's reply is `approval_comment`.
- There is no aside, because the slot map has no right-rail slot.
## `approval_comment` beside the route's own check
The declaration (`sys-approval-request.object.ts:676`):
```ts
name: 'approval_comment', label: 'Reply', type: 'api', method: 'POST',
target: '/api/v1/approvals/requests/{id}/comment',
params: [comment (textarea, required), attachments (file, multiple, optional)],
visible:
'has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true'
+ ' || has(record.status) && record.status == "pending"'
+ ' && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
locations: ['record_section'], refreshAfter: true,
```
The route's own check:
- **`packages/rest/src/rest-server.ts:12751`**: `threadRoute('comment',
...)` calls `ApprovalService.comment` with `actorId: body.actorId ??
body.actor_id ?? context?.userId`. The declared action sends no actor,
so the service acts as the caller.
- **`approval-service.ts:4828`**: `loadPendingRow` refuses with
`INVALID_STATE` unless `status === 'pending'` (`:1440`).
- **`:4830` and `:4833`**: the caller is admitted as the submitter, or
as the holder of a pending slot (`takenSlot`, which calls `heldSlot`).
- **`:4835`**: any other caller is refused with `FORBIDDEN`, unless the
context is a system one.
The viewer block (`attachViewers`, `approval-service.ts:7027`–`:7028`)
carries two flags:
- `can_act` is `status === 'pending' && heldSlot(pending, caller.userId,
caller)`. That is the same slot test, already scoped to `pending`.
- `is_submitter` has no status test, so the predicate adds the `pending`
test that `loadPendingRow` applies.
So `visible` is `can_act` OR (`pending` AND `is_submitter`). That is the
route's admission for every caller the declared action can produce. The
route admits two more callers, and the predicate leaves both out: a
system context (its viewer block is all false), and a caller who names
another identity in `actorId` (the action sends none). So the predicate
is narrower than the route there, never wider. It has no `can_override`
arm, because the route has none. An override admin who holds no slot
gets `FORBIDDEN` from the route. The parity is pinned against the real
service for eight caller shapes (see Tests).
## Done when: evidence
**1. `os validate` passes on the plugin with the page.** `node
packages/cli/bin/run.js validate
packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts` ran at
HEAD `8f30e97c3` and exited 0:
```text
✓ Validation passed (456ms)
Data: 3 Objects 45 Fields
UI: 1 Pages
```
None of the findings is located on the page: `grep -c 'at pages\['`
counts 0. The 57 warnings are all on the three objects
(`field-group-undeclared` 45, `field-no-consumers` 9,
`title-format-retired` 3), and none of them is new in this diff. A
control from a temporary `defineStack` config in the package (deleted
afterwards; `git status` empty) ran the same page with the panel type
misspelled `record:approval_decison`. It exited 1 with `rule:
component-type-unknown at pages[0].slots.actions[0].type`, and its fix
line was "Rename `record:approval_decison` →
`record:approval_decision`". The correct spelling of that config exited
0 (`UI: 1 Pages`).
**2. The metadata API serves the page for `sys_approval_request`.** **3.
`approval_comment` posts a reply that the timeline lists.** Both were
measured on the showcase dev server (fresh database, seeded admin). A
retitled announcement opens a `showcase_dynamic_approval` request, on
which the admin is both the submitter and the pending approver:
```text
HEAD 8f30e97 · showcase dev server --fresh on :42987
POST /api/v1/auth/sign-in/email -> 200
GET /api/v1/meta/page/sys_approval_request_detail -> 200
GET /api/v1/meta/pages?object=sys_approval_request -> 200
pages for sys_approval_request: ["sys_approval_request_detail"]
GET page (Accept-Language: zh-CN) -> 200 label: 审批请求
POST /api/v1/data/showcase_announcement -> 201
PATCH /api/v1/data/showcase_announcement/ID (retitle: opens showcase_dynamic_approval) -> 200
GET /api/v1/approvals/requests?object=showcase_announcement&recordId=ID -> 200
GET /api/v1/approvals/requests/REQ -> 200 {"status":"pending","viewer":{"can_act":true,"is_submitter":true,"can_override":true}}
GET /api/v1/meta/object/sys_approval_request -> 200
served approval_comment: target /api/v1/approvals/requests/{id}/comment · params [["comment","textarea",true,false],["attachments","file",false,true]]
served approval_comment.visible on the served request row -> true
POST /api/v1/approvals/requests/REQ/comment {comment} -> 200 {"status":"pending"}
GET /api/v1/approvals/requests/REQ/actions -> 200
[{"action":"submit","actor_name":"Dev Admin","comment":null},{"action":"comment","actor_name":"Dev Admin","comment":"Reply posted through the declared approval_comment target."}]
GET /api/v1/data/sys_approval_action?request_id=REQ (the Timeline related list's read) -> 200
[{"action":"comment","comment":"Reply posted through the declared approval_comment target."},{"action":"submit","comment":null}]
POST /api/v1/approvals/requests/REQ/reject -> 200
GET /api/v1/approvals/requests/REQ -> 200 {"status":"rejected","viewer":{"can_act":false,"is_submitter":true,"can_override":false}}
served approval_comment.visible on the rejected request row -> false
POST /api/v1/approvals/requests/REQ/comment on the rejected request -> 409 {"code":"INVALID_STATE","error":"request is rejected"}
```
zh-CN is the one translated locale this server serves
(`supportedLocales: ['en', 'zh-CN']`). For ja-JP and es-ES it serves the
English label. The control is the platform's own `sys_user_detail` page
and the `sys_approval_request` object label, which fall back to English
the same way there.
## Tests
The same runs at HEAD `8f30e97c3` (after `git merge origin/main` and a
rebuild of the closure):
- `pnpm --filter @objectstack/plugin-approvals exec vitest run
--maxWorkers=2`: **Test Files 65 passed (65) · Tests 939 passed (939)**,
`VERDICT command-exit 0`.
- `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0,
`check:test-typecheck: OK — 8 file(s) / 324 error(s)`. The ledger is
unchanged, and no new file carries an error.
- New, `src/sys-approval-request-page.test.ts` (16 cases):
- the manifest carries the page, and the page parses;
- the slot map: one decision node, highlights equal to `highlightFields`
minus `record_id`, the two tabs, no discussion, and every named field
exists;
- `validateComponentTypes`, `validateComponentProps` and
`runAuthoringRules('build')` are clean on the page. Each has a control
that goes red: a misspelled type, a prop on the decision node, and the
build rules on the misspelled type;
- the `en` label equals the page literal, and every locale serves its
translation.
- New, `approval-service.test.ts` → "approval_comment — its visible gate
is the comment route's admission". It runs eight caller shapes against
the real service: shown equals admitted for each, and the admin row is a
real non-arm. A reply lands on the timeline with its attachments.
- Updated: the `sys-approval-request.object.test.ts` roster, plus
`comment` in its route verbs and the declaration pins; the
`action-predicate-sparse-face.test.ts` count (8 → 9), plus the reply's
pending-only submitter arm.
**Ablations** each ran through `node scripts/ablation-replace.mjs`. Each
landed (anchor 1 → 0, blob moved), and each was restored with blob ==
HEAD and an empty `git diff HEAD`:
| Mutation | Result |
| :--- | :--- |
| `visible` gains a `can_override` arm | the parity matrix goes red on
exactly `pending · an override admin on no slot` (shown `true`, route
`FORBIDDEN`), and so does the declaration pin |
| the submitter arm loses its `pending` test | red on exactly `approved
· the submitter` and `recalled · the submitter` (shown `true`, route
`INVALID_STATE`). The first attempt's anchor missed, so the tool refused
it and wrote nothing; it was re-run with the corrected anchor |
| the `en` page label is edited | the served `es-ES` label becomes the
edited English source. The recorded digests are consulted, and both
translation pins go red |
| the zh-CN `pages` entry is dropped from the extract config |
`check:i18n-coverage` reports "untranslated declared strings grew 0 → 1"
for this config, so the ratchet counts the label |
## Gates
The gates were derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` on `8f30e97c3`. That is 65
commands, plus `pnpm check:i18n-coverage` because the extract config
moved. All 66 exited 0 at `8f30e97c3`. The reconciliation `--ran` read:
"65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero — all 65
recorded an exit code".
On the pre-merge head `de7b645ee`, `check:dual-build-cjs-loads` exited 3
with PREREQUISITE NOT MET, because eight packages outside the closure
had no `dist/`. It passed once those were built. Among the gates:
`check:i18n` (all nine bundle sets in sync), `check:i18n-stale-fill`,
`check:engine-double-contract`, `check:test-source-alias`,
`check:page-declaration-shape`, `check:published-files` and
`check:nul-bytes`.
Lint was narrowed to the 14 changed `.ts` files: `eslint
--no-inline-config --format json` gave 14 files, 0 errors, 0 warnings.
The population is `eslint.config.mjs`'s `**/*.{ts,…}` set. The config
never enables type-aware linting (its own note: "no
`parserOptions.project`, no typed `@typescript-eslint` rules"), so this
diff cannot move the verdict on any untouched file. The repo-wide `pnpm
lint` is left to CI.
## Acceptance notes
- **Translations: the PM's mechanism assumption 4 is falsified in
part.** The page label is hand-authored (`src/translations/pages.ts`),
the way `platform-objects` carries `pages.*` for `sys_user_detail` /
`sys_organization_detail` / `sys_position_detail` in its `LOCALE.ts`
files. The action's leaves did go through the tooling. The reasons, as
measured:
- This set's extract command is the objects-only one, so it emits the
`objects` sub-tree alone.
- No bundle set in the repo generates `pages`.
- A config that documents the stack-wide spelling (`--no-objects-only`)
is not carried by the gate: `flagsFromDocstring`
(`scripts/i18n-bundle-surface.mjs`) returns
`["--locales=zh-CN","--fill=default","--no-metadata-forms","--source-hashes","--out=o"]`
for it. The flag is dropped, so `check:i18n` would check an objects-only
extract against a stack-shaped bundle. This is dormant: no config
documents that spelling, and no owner is named.
- **The Details tab's grouping.** `record:details` renders the object's
field groups (`Target` / `State` / `System`), and `sys_approval_request`
declares no `fieldGroups`. `os validate` already reports this as 20
`field-group-undeclared` warnings, so those fields render in the
ungrouped bucket. This is pre-existing, unchanged here, and has no owner
named.
- **A public-door refusal for a non-participant is NOT MEASURED** on the
dev server: the showcase refuses sign-up (`POST
/api/v1/auth/sign-up/email` → 403), so there was no second account to
try. The refusal is pinned against the real service instead (`pending ·
a stranger` → `FORBIDDEN`, alongside the existing outsider test).
- The console's details/tabs precedence that shapes this page also bears
on `platform-objects`' `SysUserDetailPage`. That page authors both
`slots.details` and `slots.tabs`, so its `details` override is not
drawn. This is reported to the seat, not changed here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 25be876 commit 5495331
17 files changed
Lines changed: 797 additions & 31 deletions
File tree
- .changeset
- content/docs/automation
- packages
- plugins/plugin-approvals
- scripts
- src
- translations
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
613 | 613 | | |
614 | 614 | | |
615 | 615 | | |
616 | | - | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
617 | 619 | | |
618 | 620 | | |
619 | 621 | | |
| |||
Lines changed: 17 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
42 | 52 | | |
43 | 53 | | |
44 | 54 | | |
45 | 55 | | |
46 | 56 | | |
47 | 57 | | |
| 58 | + | |
48 | 59 | | |
49 | 60 | | |
50 | 61 | | |
51 | 62 | | |
| 63 | + | |
52 | 64 | | |
53 | 65 | | |
54 | 66 | | |
| |||
66 | 78 | | |
67 | 79 | | |
68 | 80 | | |
| 81 | + | |
69 | 82 | | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
74 | 87 | | |
75 | 88 | | |
76 | 89 | | |
| |||
Lines changed: 17 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
82 | | - | |
| 82 | + | |
83 | 83 | | |
84 | 84 | | |
85 | 85 | | |
| |||
107 | 107 | | |
108 | 108 | | |
109 | 109 | | |
110 | | - | |
| 110 | + | |
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
115 | | - | |
| 115 | + | |
116 | 116 | | |
117 | | - | |
| 117 | + | |
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
| |||
144 | 144 | | |
145 | 145 | | |
146 | 146 | | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
147 | 150 | | |
148 | 151 | | |
149 | 152 | | |
| |||
182 | 185 | | |
183 | 186 | | |
184 | 187 | | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
185 | 198 | | |
186 | 199 | | |
187 | 200 | | |
| |||
Lines changed: 118 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
| 17 | + | |
16 | 18 | | |
17 | 19 | | |
18 | 20 | | |
| |||
2048 | 2050 | | |
2049 | 2051 | | |
2050 | 2052 | | |
| 2053 | + | |
| 2054 | + | |
| 2055 | + | |
| 2056 | + | |
| 2057 | + | |
| 2058 | + | |
| 2059 | + | |
| 2060 | + | |
| 2061 | + | |
| 2062 | + | |
| 2063 | + | |
| 2064 | + | |
| 2065 | + | |
| 2066 | + | |
| 2067 | + | |
| 2068 | + | |
| 2069 | + | |
| 2070 | + | |
| 2071 | + | |
| 2072 | + | |
| 2073 | + | |
| 2074 | + | |
| 2075 | + | |
| 2076 | + | |
| 2077 | + | |
| 2078 | + | |
| 2079 | + | |
| 2080 | + | |
| 2081 | + | |
| 2082 | + | |
| 2083 | + | |
| 2084 | + | |
| 2085 | + | |
| 2086 | + | |
| 2087 | + | |
| 2088 | + | |
| 2089 | + | |
| 2090 | + | |
| 2091 | + | |
| 2092 | + | |
| 2093 | + | |
| 2094 | + | |
| 2095 | + | |
| 2096 | + | |
| 2097 | + | |
| 2098 | + | |
| 2099 | + | |
| 2100 | + | |
| 2101 | + | |
| 2102 | + | |
| 2103 | + | |
| 2104 | + | |
| 2105 | + | |
| 2106 | + | |
| 2107 | + | |
| 2108 | + | |
| 2109 | + | |
| 2110 | + | |
| 2111 | + | |
| 2112 | + | |
| 2113 | + | |
| 2114 | + | |
| 2115 | + | |
| 2116 | + | |
| 2117 | + | |
| 2118 | + | |
| 2119 | + | |
| 2120 | + | |
| 2121 | + | |
| 2122 | + | |
| 2123 | + | |
| 2124 | + | |
| 2125 | + | |
| 2126 | + | |
| 2127 | + | |
| 2128 | + | |
| 2129 | + | |
| 2130 | + | |
| 2131 | + | |
| 2132 | + | |
| 2133 | + | |
| 2134 | + | |
| 2135 | + | |
| 2136 | + | |
| 2137 | + | |
| 2138 | + | |
| 2139 | + | |
| 2140 | + | |
| 2141 | + | |
| 2142 | + | |
| 2143 | + | |
| 2144 | + | |
| 2145 | + | |
| 2146 | + | |
| 2147 | + | |
| 2148 | + | |
| 2149 | + | |
| 2150 | + | |
| 2151 | + | |
| 2152 | + | |
| 2153 | + | |
| 2154 | + | |
| 2155 | + | |
| 2156 | + | |
| 2157 | + | |
| 2158 | + | |
| 2159 | + | |
| 2160 | + | |
| 2161 | + | |
| 2162 | + | |
| 2163 | + | |
| 2164 | + | |
| 2165 | + | |
| 2166 | + | |
| 2167 | + | |
| 2168 | + | |
2051 | 2169 | | |
2052 | 2170 | | |
2053 | 2171 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
15 | 16 | | |
16 | 17 | | |
17 | 18 | | |
| |||
124 | 125 | | |
125 | 126 | | |
126 | 127 | | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
127 | 134 | | |
128 | 135 | | |
129 | 136 | | |
| |||
0 commit comments