Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 3409fe8

Browse files
fix(console): the approvals drawer's record summary shows the record page's labels and value faces (objectui#11677) (#11702)
Fixes #11677 Clause-②: no ## What changed The approvals inbox's request drawer now shows its record summary the way the record page shows the record. The only code that changed is the console page `apps/console/src/pages/system/ApprovalsInboxPage.tsx`. No published package export, registry input or locale key changes. - **The card reads the request object's field declarations.** It calls the same `getObjectSchema(object_name)` that `useHiddenFieldsByObject` already makes for this object. That read lands on the adapter's `MetadataCache`, which de-duplicates in-flight reads, so no network request is added. The new hook is `useRequestFieldDefs`. - **Labels.** A declared field takes its declared label through `useSafeFieldLabel().fieldLabel`, the translator the record page uses. The card no longer uses `payload_labels` or `prettifyKey` for it. - **Values.** A declared field's value is drawn by `RecordFieldFace`. It passes the declaration and the stored value to `resolveCellRendererType` and then `getCellRenderer` from `@object-ui/fields`, the chain `DetailSection` renders a field through, and translates the options as `DetailSection` does. This is the existing helper, imported. ⛔ There is no second formatter. A server-resolved `payload_display` value (a lookup's title) still wins. - **Rows.** When the metadata is known, a snapshot key the object does not declare gets no row, and neither does a reference with no resolved title. When the metadata is unknown (not answered yet, failed, or a schema with no fields), the card falls back to the snapshot rendering it had before. The fallback direction is the same as the `hidden` trim's. - **The lead amount** (the figure at the top of the card) follows the same rules. Its candidate selection also skips undeclared keys when the drawer has the declarations. The queue rows do not pass declarations, so their choice is unchanged. - **Two rows never share a label.** The page's private six-name system-key list is replaced by `@object-ui/types`' `isSystemManagedField`, the classifier the default list columns, record pickers and related lists already use. It also drops the injected `owner_id` column. A label two remaining rows still share is qualified with the field key (`withDistinctLabels`). - **The duplicate React key.** The summary grid rows are keyed by field key. They were keyed by label. ## The measurement, and three premises it corrected The real stack was booted: a showcase backend from objectstack `01e0f71a` (`objectstack dev --seed-admin --fresh`) and this branch's console on its own port, driven with Playwright. The same shapes were then rendered through the page in jsdom. **Before** (merge base `57d82cb`), Invoice Dual Sign-off: `Owner: Dev Admin`, `Invoice Number`, `Owner: linus@example.com`, `Region: amer`, `Status: sent`, `Issued On: 2026-04-19`, lead `Subtotal: 0`. React logged "Encountered two children with the same key" twice per drawer open, live and in jsdom. In jsdom the key argument reads `Owner`. High-Value Expense: `Approved: 0`, lead `Total: 8,900`. **After** (this branch, same live stack): Invoice: `Invoice Number: INV-1012`, `Owner: linus@example.com`, `Region: AMER`, `Status: Sent`, `Issued On: Apr 19`, `Tax Rate (%): 8`, lead `Total: 0`. These are the record page's faces for the same record: AMER and Sent badges, `Apr 19`, `TOTAL 0`. Expense: `Status: Submitted`, `Submitted On: Yesterday`, `Approved: 0`, `Reimbursable: 8900`, lead `Total: 8900`. No duplicate-key warning on open, and none after approving the invoice from the drawer. Corrected premises: 1. **The duplicate key was in none of the three candidate lists.** It was the summary grid itself. Its rows were keyed by their label, and two rows were labelled `Owner`. 2. **`owner_id` is a declared field.** The registry injects it on every business object as a `system: true` lookup labelled "Owner". The live metadata read and `payload_labels` both say so. The showcase invoice also declares its own text `owner` (its row-level-security anchor) with the same label, so the collision is between two declarations, not between a declaration and a stray key. Dropping `owner_id` as bookkeeping is what the platform's other default surfaces already do. The label qualifier is the backstop for any other pair of fields with one label. 3. **`Approved 0` is not a boolean.** `showcase_expense_report` declares no boolean field. "Approved" is the label of `approved_amount`, a `summary` roll-up, and its value for the demo report is 0. Its record-page face is the summary cell, which prints the stored number. The invoice's "Subtotal" is a `summary` too. ## Tests New pin file `ApprovalsInboxPage.summaryFaces-11677.test.tsx`, 8 tests. Its fixtures follow the live shapes: the snapshot key order, the nulls, the hydrated `account`, `payload_display` with `owner_id` only, both `Owner` labels in `payload_labels`, and the metadata read's `Total` against the snapshot's `Subtotal`. Each value is compared with the record page's own dispatch rendered standalone, next to literal anchors (`EMEA`, `Sent`, raw strings absent). The file covers: both measured requests, one `Owner`, no duplicate-key warning, the lead's label and face, undeclared keys, two declared fields sharing a label, and the fail-open path. Fixtures updated beside it: - `hiddenFieldTrim` declares every snapshot key it expects on the card. Under the new rule an undeclared key takes no row once metadata has answered. The trim and the promotion it pins are unchanged. - `characterizationPins` moves its `_id`-trim fixture from `owner_id` to `vendor_id`, because `owner_id` is now dropped as bookkeeping and never reaches `prettifyKey`. - `rawPayloadGate`: a header comment now names the classifier instead of the removed private list. Reverse verification, committed fix first, then each leg restored and proven (blob equal to HEAD, `git diff HEAD` empty): - the page at the merge base: 8 of 8 red; - the private six-name list put back in place of `isSystemManagedField`: 3 red (the two `Owner` tests and the fail-open test); - `withDistinctLabels` removed: 1 red (two declared fields sharing a label); - HEAD: 8 of 8 green. The duplicate-key assertions sit after the label assertions in each test, so on a defective tree the label assertion goes red first. Their red direction is the merge-base measurement above, not those assertion lines themselves. Gates, local, at the head this body names: All at head `e4b9a61`. Exit codes were captured before any pipe. | command | exit | verdict line | |---|---|---| | `pnpm exec vitest run apps/console/` (the console project) | 0 | `Test Files 151 passed (151)`, `Tests 1783 passed (1783)` | | `pnpm exec vitest run apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx` | 0 | `Tests 8 passed (8)` | | `pnpm --filter @object-ui/console type-check` (after `turbo run build --filter='@object-ui/console^...'`) | 0 | `tsc --noEmit && tsc -b tsconfig.node.json --force` clean; `--listFiles` includes the new test file | | `pnpm --filter @object-ui/console lint` | 0 | `0 errors`; this page has the same warnings as the merge base, none new | | `pnpm check:new-line-citations` | 0 | `0 new citation(s)` | | `pnpm check:control-bytes` | 0 | `OK` | | `pnpm check:changeset-claims` | 0 | `No pending changeset names a file this change touches` | | `pnpm check:pending-changeset-literals` | 0 | `No test source names a pending changeset` | | `pnpm check:handler-key-reads` | 0 | `OK` | | `node scripts/check-changeset-no-major.mjs` | 0 | `No changeset declares a major bump` | | `node scripts/check-changeset-presence.mjs` | 0 | `5 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)` | | `node scripts/check-changeset-fixed.mjs` | 0 | `All workspace packages are in the changeset fixed group` | | `node scripts/check-changeset-overwrite.mjs` | 0 | `No pre-existing changeset was modified or deleted` | | `pnpm check:vi-mock-override-shape` | 0 | `OK` | | `pnpm check:test-path-roots` | 0 | `OK` | | `pnpm check:phantom-deps` | 0 | `Every in-scope import is declared by the package that publishes it` | | `pnpm check:self-import` | 0 | `No package names itself inside its own src/` | | `pnpm check:i18n-keys` | 0 | every in-scope call-site key resolves | Not run locally, declared to CI: the repository-wide `pnpm lint` / `pnpm test` / `pnpm type-check` farm. The local runs are the console package's own lint, type-check and test project. `turbo` affected-set reasoning was not used. ## Acceptance notes - **Summary roll-ups print ungrouped.** The two measured leads are `summary` fields. Through the record page's summary cell (`FormulaCellRenderer`) they print the stored number in mono, without digit grouping or currency: `8900` where the old card printed `8,900`. The record page draws them the same way. The gap belongs to that cell in `@object-ui/fields` and is outside this card's file surface. Carrier: none. - **Two label sources disagree in the showcase.** The showcase translation bundle labels `showcase_invoice.total` "Total" and the object declares "Subtotal". The metadata read serves the bundle's label, which the record page shows and this card now shows. `payload_labels` carries the object's own label. The spec documents `payload_labels` as localized only for a single-locale project. Observation only. - objectui#2763 (parked for v18) plans to rebuild this surface. This change does not start that rebuild. --- _Generated by [Claude Code](https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce464d9 commit 3409fe8

6 files changed

Lines changed: 783 additions & 57 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
The approvals inbox's request drawer shows its record summary the way the record page shows the record (objectui#11677).
6+
7+
The summary card now reads the field declarations of the request's object. It asks the same cached metadata read the drawer already makes for `hidden: true`, so no network request is added. With those declarations:
8+
9+
- **Labels.** Each row takes its field's declared label, translated as the record page translates it, instead of the server's snapshot label or a title-cased key.
10+
- **Values.** Each value is drawn by the record page's own cell for its field type. An option shows its label (`Sent`, `EMEA` instead of `sent`, `emea`), a date shows as the date cell shows it, and a currency field shows in its currency. A lookup still shows the record title the server resolved. The lead amount at the top of the card follows the same rule. A `summary` roll-up shows as the record page shows it: the plain stored number, without digit grouping.
11+
- **Rows.** A snapshot key the object does not declare gets no row. Neither does a reference the server could not resolve to a title, because its value is an id.
12+
13+
If the object's metadata cannot be read, the card renders the snapshot as before.
14+
15+
Two rows never share a label. The card now drops the platform's injected ownership column `owner_id` (label "Owner") as bookkeeping, the same way the default list columns do, so an object that also declares its own `owner` field no longer shows two "Owner" rows. If two remaining fields still share a label, each of those rows adds its field key in parentheses. The rows are keyed by field key rather than by label, so React no longer warns about two children with the same key.

‎apps/console/src/pages/system/ApprovalsInboxPage.characterizationPins.test.tsx‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* plus the "Flow-initiated" cell, instead of a bare person icon + em dash
1919
* (#2803).
2020
* 3. **Record-card copy and emphasis** — `prettifyKey()` drops a trailing
21-
* `id` token (`owner_id` → "Owner") and `decisionAmountEntry()` promotes
21+
* `id` token (`vendor_id` → "Vendor") and `decisionAmountEntry()` promotes
2222
* the decision amount to a lead figure, excluded from the generic field
2323
* grid so it renders exactly once (#2803).
2424
*
@@ -41,7 +41,7 @@
4141
* two people to conflate;
4242
* - a **`flow:`-prefixed submitter id** with no `submitter_name` — the shape
4343
* a real flow-initiated request arrives in;
44-
* - a payload carrying **both `owner_id` and `amount`** — `owner_id` proves
44+
* - a payload carrying **both `vendor_id` and `amount`** — `vendor_id` proves
4545
* the id-token trim (a bare `id` key, or one with a resolvable non-`_id`
4646
* name, would not exercise `prettifyKey`'s trim branch at all), `amount`
4747
* proves both the lead-figure promotion and the single-render exclusion.
@@ -71,9 +71,9 @@ const APP = 'com.objectstack.account';
7171
// all imports) — so the two display-value fixtures live INSIDE it and are
7272
// destructured out below, rather than being plain top-level `const`s the
7373
// hoisted block would close over before they existed.
74-
const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, OWNER_DISPLAY, AMOUNT_DISPLAY } = vi.hoisted(() => {
75-
/** The server-resolved display value for `owner_id` — never opaque-dropped. */
76-
const OWNER_DISPLAY = 'Jordan Lee';
74+
const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, VENDOR_DISPLAY, AMOUNT_DISPLAY } = vi.hoisted(() => {
75+
/** The server-resolved display value for `vendor_id` — never opaque-dropped. */
76+
const VENDOR_DISPLAY = 'Northwind Labs';
7777
/** The server-formatted decision amount, as `payload_display` would send it. */
7878
const AMOUNT_DISPLAY = 'USD 42,000.00';
7979

@@ -100,11 +100,14 @@ const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, OWNER_DISPLAY, A
100100
// Case 3 fixture: an `_id`-suffixed key (prettifyKey's trim branch) and
101101
// an amount-like key (decisionAmountEntry's lead figure), together.
102102
payload: {
103-
owner_id: 'usr_9f3ma2xk1pqz88a',
103+
// `owner_id` stood here until objectui#11677. It is the platform's
104+
// injected ownership column, which the card now drops as bookkeeping
105+
// (`isSystemManagedField`), so it never reaches `prettifyKey`'s trim.
106+
vendor_id: 'vnd_9f3ma2xk1pqz88a',
104107
amount: 42000,
105108
},
106109
payload_display: {
107-
owner_id: OWNER_DISPLAY,
110+
vendor_id: VENDOR_DISPLAY,
108111
amount: AMOUNT_DISPLAY,
109112
},
110113
};
@@ -129,7 +132,7 @@ const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, OWNER_DISPLAY, A
129132
language: 'en',
130133
};
131134

132-
return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, OWNER_DISPLAY, AMOUNT_DISPLAY };
135+
return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, VENDOR_DISPLAY, AMOUNT_DISPLAY };
133136
});
134137

135138
vi.mock('@object-ui/i18n', async (importOriginal) => ({
@@ -228,13 +231,13 @@ describe('Approvals drawer — a flow-initiated request names its origin (object
228231
});
229232

230233
describe('Approvals drawer — record-card copy and emphasis (objectui#2803, #6395)', () => {
231-
it('reads `owner_id` as "Owner" and leads with the decision amount exactly once', async () => {
234+
it('reads `vendor_id` as "Vendor" and leads with the decision amount exactly once', async () => {
232235
const dialog = await openedDrawer();
233236

234237
// prettifyKey: the trailing `id` token is dropped, so the field label
235-
// reads "Owner", not "Owner Id".
236-
expect(within(dialog).getByText('Owner')).toBeInTheDocument();
237-
expect(within(dialog).getByText(OWNER_DISPLAY)).toBeInTheDocument();
238+
// reads "Vendor", not "Vendor Id".
239+
expect(within(dialog).getByText('Vendor')).toBeInTheDocument();
240+
expect(within(dialog).getByText(VENDOR_DISPLAY)).toBeInTheDocument();
238241

239242
// decisionAmountEntry: the amount is promoted to the lead figure AND
240243
// excluded from the generic field grid, so its display value renders

‎apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ async function openedDrawer(): Promise<HTMLElement> {
194194
return dialog;
195195
}
196196

197+
/**
198+
* Every snapshot key declared as a text field. Since objectui#11677 a snapshot
199+
* key the object's metadata does not declare takes no row once that metadata
200+
* has answered, so a fixture that wants a key on the card declares it.
201+
*/
202+
const EVERY_PAYLOAD_FIELD: Record<string, Record<string, unknown>> = Object.fromEntries(
203+
Object.keys(ROW.payload as Record<string, unknown>).map((k) => [k, { type: 'text' }]),
204+
);
205+
197206
/** The card really rendered its business content — run by every denial case. */
198207
function expectSummaryCardIntact(dialog: HTMLElement): void {
199208
for (const value of SIBLING_VALUES) {
@@ -216,11 +225,7 @@ afterEach(cleanup);
216225
describe('Approvals drawer summary card — `hidden: true` trim (objectui#5565)', () => {
217226
it('drops the hidden field and promotes the seventh into its slot', async () => {
218227
getObjectSchema.mockResolvedValue({
219-
fields: {
220-
subject: { type: 'text' },
221-
diagnosis_code: { type: 'text', hidden: true },
222-
justification: { type: 'text' },
223-
},
228+
fields: { ...EVERY_PAYLOAD_FIELD, diagnosis_code: { type: 'text', hidden: true } },
224229
});
225230
const dialog = await openedDrawer();
226231

@@ -238,9 +243,7 @@ describe('Approvals drawer summary card — `hidden: true` trim (objectui#5565)'
238243
});
239244

240245
it('COUNTER-PROBE: same fixture, same position, nothing declared hidden — it renders', async () => {
241-
getObjectSchema.mockResolvedValue({
242-
fields: { subject: { type: 'text' }, diagnosis_code: { type: 'text' } },
243-
});
246+
getObjectSchema.mockResolvedValue({ fields: EVERY_PAYLOAD_FIELD });
244247
const dialog = await openedDrawer();
245248

246249
await waitFor(() => expect(getObjectSchema).toHaveBeenCalledWith(OBJECT));
@@ -277,7 +280,7 @@ describe('Approvals drawer lead amount — same card, same trim (objectui#5565)'
277280

278281
it('does not lead with an amount field the object declares hidden', async () => {
279282
useAmountFixture();
280-
getObjectSchema.mockResolvedValue({ fields: { total_amount: { hidden: true } } });
283+
getObjectSchema.mockResolvedValue({ fields: { total_amount: { hidden: true }, subject: { type: 'text' } } });
281284
const dialog = await openedDrawer();
282285

283286
await waitFor(() => expect(getObjectSchema).toHaveBeenCalledWith(OBJECT));
@@ -290,7 +293,7 @@ describe('Approvals drawer lead amount — same card, same trim (objectui#5565)'
290293

291294
it('COUNTER-PROBE: the same amount, undeclared, still leads the card', async () => {
292295
useAmountFixture();
293-
getObjectSchema.mockResolvedValue({ fields: { total_amount: {} } });
296+
getObjectSchema.mockResolvedValue({ fields: { total_amount: {}, subject: { type: 'text' } } });
294297
const dialog = await openedDrawer();
295298

296299
await waitFor(() => expect(getObjectSchema).toHaveBeenCalledWith(OBJECT));

‎apps/console/src/pages/system/ApprovalsInboxPage.rawPayloadGate.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
*
2828
* ## The witnesses
2929
*
30-
* `created_by` and `organization_id` are in the page's `PAYLOAD_SYSTEM_KEYS`,
30+
* `created_by` and `organization_id` are bookkeeping columns to the page's
31+
* `isBookkeepingKey` (`@object-ui/types`' `isSystemManagedField`, objectui#11677),
3132
* so the summary card already drops them: their values can reach the DOM only
3233
* through the raw panel. That makes them exact witnesses for "the panel
3334
* rendered", not proxies. (Both are also long enough to trip the summary's

0 commit comments

Comments
 (0)