From 588c2c5a543ce9a9e456bbbae56122c8587714b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:25:40 +0000 Subject: [PATCH] fix(console): trim `hidden: true` fields from the approvals drawer summary card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `payloadSummary` built the drawer's business summary card from the request's `payload_json` snapshot behind five filters — `PAYLOAD_SYSTEM_KEYS`, the lead amount key, null/object/empty values, unresolved opaque ids, a six-field cut — and no field-visibility filter of any kind. A field the object's metadata declares `hidden: true` was an ordinary scalar to that code, so it rendered in the card, labelled via `payload_labels`. Per the platform ruling, `hidden: true` stays UI-only and `internal: true` is the serialization primitive: the producer is correct to ship a `hidden` field in the snapshot, and FLS-restricted fields are already redacted at serve time. `hidden` is a UI contract and this drawer card is default UI, so the UI is the authoritative place that contract is enforced. The drawer now reads the open request's object metadata (`getObjectSchema`, the same cached `GET /meta/object/:name` the record form performs) and drops the declared-hidden keys BEFORE the six-field cut, so the next business field is promoted into the freed slot. The lead amount figure at the top of the same card takes the same trim. An unanswered metadata read leaves the card exactly as it renders today: this is a presentation filter, not an access control. No new published export: the seam consumes `useAdapter`, already exported from `@object-ui/app-shell` and already used by this page's `recordReadability`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- .changeset/plain-taxis-attend.md | 22 ++ ...pprovalsInboxPage.hiddenFieldTrim.test.tsx | 295 ++++++++++++++++++ .../src/pages/system/ApprovalsInboxPage.tsx | 38 ++- .../src/pages/system/hiddenFields.test.ts | 79 +++++ apps/console/src/pages/system/hiddenFields.ts | 164 ++++++++++ 5 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 .changeset/plain-taxis-attend.md create mode 100644 apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx create mode 100644 apps/console/src/pages/system/hiddenFields.test.ts create mode 100644 apps/console/src/pages/system/hiddenFields.ts diff --git a/.changeset/plain-taxis-attend.md b/.changeset/plain-taxis-attend.md new file mode 100644 index 0000000000..4b7cd354ce --- /dev/null +++ b/.changeset/plain-taxis-attend.md @@ -0,0 +1,22 @@ +--- +'@object-ui/console': patch +--- + +Approvals drawer: the business summary card no longer renders a field the object +declares `hidden: true`. + +`payloadSummary` built the card from the request's `payload_json` snapshot behind +five filters (system keys, the lead amount key, null/object/empty values, +unresolved opaque ids, a six-field cut) and no field-visibility filter, so a +hidden field that survived to the first six survivors rendered in the card, +labelled. The drawer now reads the open request's object metadata and drops the +declared-hidden keys before the six-field cut, so the next business field is +promoted into the freed slot rather than the card silently shrinking. The lead +amount figure at the top of the same card takes the same trim. + +Per the platform ruling, `hidden: true` is a UI-only contract and `internal: true` +is the serialization primitive, so this is the UI enforcing the only contract +`hidden` has — not a client-side compensation. Field-level security is unchanged +and remains the server's answer. The metadata read is the same cached +`GET /meta/object/:name` the record form already performs, once per object per +page visit, and an unanswered read leaves the card exactly as it renders today. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx new file mode 100644 index 0000000000..84c5cd2a38 --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approvals drawer — the business summary card honours the object's + * `hidden: true` declaration (objectui#5565). + * + * ## The defect + * + * `payloadSummary` built the drawer's summary card from the request's + * `payload_json` snapshot behind five filters (system keys, the lead amount + * key, null/object/empty values, unresolved opaque ids, a 6-field cut) and + * **no field-visibility filter of any kind**. A field the object's metadata + * declares `hidden: true` was an ordinary scalar to that code, so it rendered + * in the card, labelled. + * + * ## Why the fix is client-side, and why that is not a workaround + * + * Maintainer ruling on objectstack#10749: *`hidden: true` stays UI-only; + * `internal: true` is the serialization primitive*. `hidden` gains no + * serialization semantic, so the producer is CORRECT to ship the field, and + * FLS-restricted fields are already redacted at serve time (objectstack#11039) + * and are not this card's subject. `hidden` is a UI contract — "hidden from the + * default UI" — and this drawer card is default UI, so the UI is the + * authoritative place that contract is enforced, not a compensating one. + * + * ## ⚠️ Why the fixture is shaped the way it is — the 6-field cut is a + * confounder in BOTH directions + * + * A hidden field only ever rendered if it survived to the first 6 survivors, so + * a fixture that parks it seventh proves nothing; and a card that merely + * reordered its rows is not a fix. So the fixture pins the FILTER: + * + * 1 subject · 2 vendor · 3 diagnosis_code (the hidden one, well inside the + * cut) · 4 department · 5 urgency · 6 ledger_ref · 7 justification · 8 notes + * + * Untrimmed the card shows 1-6. Trimmed it shows 1,2,4,5,6 **and 7** — the + * seventh field is promoted into the slot the hidden field vacated, which can + * only happen if the drop precedes the `max` cut. `notes` (8) stays out either + * way, so "promotion" cannot be an off-by-one that simply renders more. + * + * ## The counter-probe, and why it is the important half + * + * "The hidden field is gone" is satisfiable by breaking the summary card + * entirely. So the SAME fixture, in the SAME position, is rendered through the + * SAME helper against an object that declares nothing hidden — and there + * `diagnosis_code` renders. Every denial case also asserts its sibling business + * fields, so an empty card fails here before it can pass anywhere. + * + * ## Why `setup.access` + * + * The raw-JSON panel (objectui#5553) would print the whole snapshot, hidden + * field included, by a second route. Every render here is a business approver + * holding `setup.access` — a real, reported grant that is deliberately NOT one + * of the platform-admin-only capabilities — so that panel never renders and the + * summary card is the only door under test. + * + * No build artifact sits between the edit and this test: the root Vitest config + * aliases every `@object-ui` specifier at that package's source directory, and + * the page under test is this app's own source. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, within, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +const APP = 'com.objectstack.account'; +const OBJECT = 'showcase_purchase'; + +/** The author's `hidden: true` field, third in the snapshot — inside the cut. */ +const HIDDEN_VALUE = 'F32.1 major depressive'; +/** Seventh in the snapshot: renders ONLY once the hidden field is dropped. */ +const PROMOTED_VALUE = 'Replacing expired stock'; +/** Eighth: out of the card either way, so promotion is not just "more rows". */ +const NEVER_VALUE = 'Reviewed by pharmacy'; +/** Ordinary business fields — the counter-anchor against an empty card. */ +const SIBLING_VALUES = ['Q3 clinical supplies', 'Northwind Labs', 'Cardiology', 'Routine']; + +const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, ROW } = vi.hoisted(() => { + // Widened on purpose: the amount fixture below re-shapes `payload`, and a + // literal-inferred row would make that a type error in the test rather than + // in anything under test. + const ROW: Record = { + id: 'req_1', + process_name: 'purchase_approval', + process_label: 'Purchase Approval', + object_name: 'showcase_purchase', + object_label: 'Purchase', + record_id: 'po_1', + record_title: 'PO-4417', + status: 'pending', + pending_approvers: ['u_1'], + submitter_id: 'u_2', + submitter_name: 'Sam Submitter', + submitted_at: '2026-08-20T00:00:00.000Z', + payload: { + subject: 'Q3 clinical supplies', + vendor: 'Northwind Labs', + diagnosis_code: 'F32.1 major depressive', + department: 'Cardiology', + urgency: 'Routine', + ledger_ref: 'LR-4417', + justification: 'Replacing expired stock', + notes: 'Reviewed by pharmacy', + }, + }; + + const getObjectSchema = vi.fn(async (_name: string): Promise => ({ fields: {} })); + + const approvalsApiStub = { + listRequests: vi.fn(async () => ({ data: [ROW], total: 1 })), + getRequest: vi.fn(async () => ({ data: ROW })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: ROW, finalized: true })), + reject: vi.fn(async () => ({ data: ROW, finalized: true })), + }; + + // STABLE singletons: a mocked hook handing back a fresh object per render + // re-runs the page's load effect forever and the drawer never settles. + const ADAPTER = { find: vi.fn(async () => ({ data: [{ id: 'po_1' }] })), getObjectSchema }; + const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; + const I18N = { + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }; + + return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, ROW }; +}); + +vi.mock('@object-ui/i18n', () => ({ useObjectTranslation: () => I18N })); + +vi.mock('@object-ui/auth', () => { + const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); + return { + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', () => ({ + useAdapter: () => ADAPTER, + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +// Imported after the mocks so the page picks them up. +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +function permissionsPayload(): MePermissionsResponse { + return { + authenticated: true, + userId: 'u_1', + tenantId: 't_1', + roles: [], + permissionSets: [], + objects: {}, + fields: {}, + // A real, reported grant that is NOT platform-admin-only: the raw-JSON + // panel (objectui#5553) stays shut, so the summary card is the only door. + systemPermissions: ['setup.access'], + }; +} + +/** Open the drawer the way a notification opens it — the `?request=` deep link. */ +function renderDrawer() { + return render( + + + + } /> + + + , + ); +} + +/** Render the page and hand back the drawer, once the request has loaded in. */ +async function openedDrawer(): Promise { + renderDrawer(); + const dialog = await screen.findByRole('dialog'); + await within(dialog).findByText('Purchase Approval'); + return dialog; +} + +/** The card really rendered its business content — run by every denial case. */ +function expectSummaryCardIntact(dialog: HTMLElement): void { + for (const value of SIBLING_VALUES) { + expect(within(dialog).getByText(value)).toBeInTheDocument(); + } +} + +beforeEach(() => { + ADAPTER.find.mockClear(); + getObjectSchema.mockClear(); + getObjectSchema.mockResolvedValue({ fields: {} }); + for (const fn of Object.values(approvalsApiStub)) fn.mockClear(); + // `mockClear` keeps implementations, so restore the default row explicitly — + // otherwise the amount fixture below would leak into a later test. + approvalsApiStub.listRequests.mockResolvedValue({ data: [ROW], total: 1 }); + approvalsApiStub.getRequest.mockResolvedValue({ data: ROW }); +}); +afterEach(cleanup); + +describe('Approvals drawer summary card — `hidden: true` trim (objectui#5565)', () => { + it('drops the hidden field and promotes the seventh into its slot', async () => { + getObjectSchema.mockResolvedValue({ + fields: { + subject: { type: 'text' }, + diagnosis_code: { type: 'text', hidden: true }, + justification: { type: 'text' }, + }, + }); + const dialog = await openedDrawer(); + + // The promoted field is the settlement signal AND the proof that the drop + // precedes the 6-field cut: seventh in the snapshot, it can only render + // once the third has been filtered out. + expect(await within(dialog).findByText(PROMOTED_VALUE)).toBeInTheDocument(); + + expect(within(dialog).queryByText(HIDDEN_VALUE)).not.toBeInTheDocument(); + // Not an off-by-one that merely renders one row more. + expect(within(dialog).queryByText(NEVER_VALUE)).not.toBeInTheDocument(); + expectSummaryCardIntact(dialog); + + expect(getObjectSchema).toHaveBeenCalledWith(OBJECT); + }); + + it('COUNTER-PROBE: same fixture, same position, nothing declared hidden — it renders', async () => { + getObjectSchema.mockResolvedValue({ + fields: { subject: { type: 'text' }, diagnosis_code: { type: 'text' } }, + }); + const dialog = await openedDrawer(); + + await waitFor(() => expect(getObjectSchema).toHaveBeenCalledWith(OBJECT)); + expect(within(dialog).getByText(HIDDEN_VALUE)).toBeInTheDocument(); + // Untrimmed the card is full at six, so the seventh stays out — the mirror + // image of the promotion above. + expect(within(dialog).queryByText(PROMOTED_VALUE)).not.toBeInTheDocument(); + expectSummaryCardIntact(dialog); + }); + + it('FAILS OPEN: a source that cannot describe the object renders today’s card', async () => { + getObjectSchema.mockRejectedValue(Object.assign(new Error('Forbidden'), { status: 403 })); + const dialog = await openedDrawer(); + + await waitFor(() => expect(getObjectSchema).toHaveBeenCalledWith(OBJECT)); + expect(within(dialog).getByText(HIDDEN_VALUE)).toBeInTheDocument(); + expectSummaryCardIntact(dialog); + }); +}); + +describe('Approvals drawer lead amount — same card, same trim (objectui#5565)', () => { + /** The bold figure at the top of the summary card, as the server formats it. */ + const AMOUNT_DISPLAY = 'USD 12,500.00'; + + function useAmountFixture() { + const row = { + ...ROW, + payload: { total_amount: 12500, subject: 'Ventilator service contract' }, + payload_display: { total_amount: AMOUNT_DISPLAY }, + }; + approvalsApiStub.listRequests.mockResolvedValue({ data: [row], total: 1 }); + approvalsApiStub.getRequest.mockResolvedValue({ data: row }); + } + + it('does not lead with an amount field the object declares hidden', async () => { + useAmountFixture(); + getObjectSchema.mockResolvedValue({ fields: { total_amount: { hidden: true } } }); + const dialog = await openedDrawer(); + + await waitFor(() => expect(getObjectSchema).toHaveBeenCalledWith(OBJECT)); + expect(within(dialog).queryByText(AMOUNT_DISPLAY)).not.toBeInTheDocument(); + // The card is still a card: the hidden amount did not take the drawer with + // it — and the counter-probe below renders this exact figure from this + // exact fixture, so the absence above is the filter, not an empty drawer. + expect(within(dialog).getByText('Ventilator service contract')).toBeInTheDocument(); + }); + + it('COUNTER-PROBE: the same amount, undeclared, still leads the card', async () => { + useAmountFixture(); + getObjectSchema.mockResolvedValue({ fields: { total_amount: {} } }); + const dialog = await openedDrawer(); + + await waitFor(() => expect(getObjectSchema).toHaveBeenCalledWith(OBJECT)); + expect(within(dialog).getByText(AMOUNT_DISPLAY)).toBeInTheDocument(); + expect(within(dialog).getByText('Ventilator service contract')).toBeInTheDocument(); + }); +}); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 76320b0a15..f3fb6d4418 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -105,6 +105,7 @@ import { type ApprovalActionAttachment, } from '../../services/approvalsApi'; import { useRecordReadability } from './recordReadability'; +import { useHiddenFields } from './hiddenFields'; import { holdsStudioAccess } from '../../components/studioEntry'; type TabKey = 'pending' | 'submitted' | 'all'; @@ -313,6 +314,18 @@ const OPAQUE_ID_RE = /^[A-Za-z0-9_-]{15,}$/; * card. Lookup foreign keys render their server-resolved record title * (`payload_display`); an unresolved opaque id is dropped rather than shown — * a business reader gets nothing from `dpOfPMy7cbeEL1jk`. + * + * `hiddenKeys` carries the object's `hidden: true` declarations (objectui#5565) + * and is dropped BEFORE the `max` cut, not after: this card is default UI, so a + * field the author hid must not occupy one of its slots — and the field that + * would have been seventh is promoted into the freed slot rather than the card + * simply rendering one row shorter. That ordering is what makes this a filter + * rather than a reshuffle; see `ApprovalsInboxPage.hiddenFieldTrim.test.tsx`, + * which pins both halves. + * + * An empty `hiddenKeys` means "nothing known to be hidden" — including the case + * where the metadata read has not answered — and renders today's card. See + * `hiddenFields.ts` on why this presentation filter fails open. */ function payloadSummary( payload: unknown, @@ -320,11 +333,13 @@ function payloadSummary( labels?: Record, max = 6, excludeKey?: string, + hiddenKeys?: ReadonlySet, ): Array<[string, string]> { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []; const out: Array<[string, string]> = []; for (const [k, v] of Object.entries(payload as Record)) { if (PAYLOAD_SYSTEM_KEYS.has(k)) continue; + if (hiddenKeys?.has(k)) continue; // author declared `hidden: true` (#5565) if (excludeKey && k === excludeKey) continue; // shown as the lead amount if (v == null || typeof v === 'object') continue; if (String(v).trim() === '') continue; @@ -355,11 +370,13 @@ const AMOUNT_KEY_RE = /(amount|total|price|value|cost|sum|budget|salary|fee|reve */ function decisionAmountEntry( r: ApprovalRequestRow, + hiddenKeys?: ReadonlySet, ): { key: string; label: string; value: number; display: string } | null { const payload = r.payload; if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; for (const [k, v] of Object.entries(payload as Record)) { if (PAYLOAD_SYSTEM_KEYS.has(k)) continue; + if (hiddenKeys?.has(k)) continue; // author declared `hidden: true` (#5565) if (!AMOUNT_KEY_RE.test(k)) continue; const num = typeof v === 'number' ? v @@ -689,6 +706,19 @@ export function ApprovalsInboxPage() { [rows, selected], ); const readability = useRecordReadability(readabilityTargets); + + /** + * objectui#5565 — the fields the open request's object declares + * `hidden: true`. The drawer's business summary card is default UI, and + * `hidden` is a UI contract (objectstack#10749: "`hidden: true` stays + * UI-only; `internal: true` is the serialization primitive"), so the card + * must honour it. Scoped to the OPEN request's object: the queue rows are a + * different surface with a different cost model, and are not trimmed here. + * + * One cached metadata read per object per mount, empty until it answers — + * see `hiddenFields.ts` for the cost model and why this fails open. + */ + const hiddenPayloadKeys = useHiddenFields(selected?.object_name); // Approve/reject/reassign/send-back/… are server-declared actions rendered by // DeclaredActionsBar (objectui#2697 + framework#3300); their param dialog // collects the comment and — since the shared upload-widget renderer (#2700/ @@ -1808,8 +1838,12 @@ export function ApprovalsInboxPage() { // Decision-critical amount leads the card (#2762 P2) — a filled // figure at the top instead of a value buried bottom-right in the // generic field grid. Excluded from that grid below so it shows once. - const drawerAmount = decisionAmountEntry(selected); - const summary = payloadSummary(selected.payload, selected.payload_display, selected.payload_labels, 6, drawerAmount?.key); + // Both halves of this card read the same snapshot, so both take + // the same `hidden` trim (objectui#5565) — otherwise a hidden + // amount-like field would simply move from the field grid to the + // bold lead figure at the top of the very card being fixed. + const drawerAmount = decisionAmountEntry(selected, hiddenPayloadKeys); + const summary = payloadSummary(selected.payload, selected.payload_display, selected.payload_labels, 6, drawerAmount?.key, hiddenPayloadKeys); return ( diff --git a/apps/console/src/pages/system/hiddenFields.test.ts b/apps/console/src/pages/system/hiddenFields.test.ts new file mode 100644 index 0000000000..1cf9d40965 --- /dev/null +++ b/apps/console/src/pages/system/hiddenFields.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `hidden: true` reader behind the Approvals drawer trim (objectui#5565). + * + * Pins the two things the render test cannot see on its own: that BOTH served + * `fields` shapes are read, and that every not-an-answer resolves to the empty + * set rather than to a guess — the fail-open direction the drawer depends on. + * + * ⛔ Nothing here reads `internal`. Per the maintainer's ruling on + * objectstack#10749 (`hidden: true` stays UI-only; `internal: true` is the + * serialization primitive) those are distinct primitives, and a test that + * accepted either would license conflating them in the code. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { hiddenFieldNames, readHiddenFields } from './hiddenFields'; + +describe('hiddenFieldNames — both served `fields` shapes', () => { + it('reads the record shape (`{ name: def }`)', () => { + const hidden = hiddenFieldNames({ + fields: { + subject: { type: 'text' }, + diagnosis_code: { type: 'text', hidden: true }, + internal_scratch: { type: 'text', internal: true }, + }, + }); + expect([...hidden]).toEqual(['diagnosis_code']); + // `internal` is a different primitive and is NOT this filter's business. + expect(hidden.has('internal_scratch')).toBe(false); + }); + + it('reads the array shape (`[{ name, ...def }]`)', () => { + const hidden = hiddenFieldNames({ + fields: [ + { name: 'subject', type: 'text' }, + { name: 'diagnosis_code', type: 'text', hidden: true }, + { name: '', type: 'text', hidden: true }, + ], + }); + expect([...hidden]).toEqual(['diagnosis_code']); + }); + + it('is strict about `=== true` — a truthy non-true value is not a declaration', () => { + const hidden = hiddenFieldNames({ + fields: { + a: { hidden: 'false' }, + b: { hidden: 1 }, + c: { hidden: true }, + }, + }); + expect([...hidden]).toEqual(['c']); + }); + + it('yields the empty set for every unreadable schema shape', () => { + for (const schema of [null, undefined, {}, { fields: null }, { fields: 'nope' }, 42]) { + expect(hiddenFieldNames(schema).size).toBe(0); + } + }); +}); + +describe('readHiddenFields — every not-an-answer is the empty set', () => { + it('resolves empty when the source cannot describe objects at all', async () => { + expect((await readHiddenFields({}, 'showcase_purchase')).size).toBe(0); + expect((await readHiddenFields(null, 'showcase_purchase')).size).toBe(0); + }); + + it('resolves empty — never rejects — when the read throws', async () => { + const getObjectSchema = vi.fn(async () => { throw new Error('403'); }); + await expect(readHiddenFields({ getObjectSchema }, 'showcase_purchase')) + .resolves.toEqual(new Set()); + expect(getObjectSchema).toHaveBeenCalledWith('showcase_purchase'); + }); + + it('returns the declared names when the read answers', async () => { + const getObjectSchema = vi.fn(async () => ({ fields: { x: { hidden: true } } })); + expect([...(await readHiddenFields({ getObjectSchema }, 'o'))]).toEqual(['x']); + }); +}); diff --git a/apps/console/src/pages/system/hiddenFields.ts b/apps/console/src/pages/system/hiddenFields.ts new file mode 100644 index 0000000000..ee37ac70d3 --- /dev/null +++ b/apps/console/src/pages/system/hiddenFields.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which fields of an object the app author declared `hidden: true` + * (objectui#5565). + * + * ## What this exists to enforce, and why here + * + * `hidden: true` is a **UI contract** — "hidden from the default UI" — and the + * maintainer's ruling on objectstack#10749 pinned it there: *`hidden: true` + * stays UI-only; `internal: true` is the serialization primitive*. A field an + * author wants off the wire entirely is declared `internal: true` and never + * reaches a client at all; a field-level access control is FLS, which the + * server applies at serve time (`getReadableFields`, objectstack#11039). + * + * `hidden` is neither of those. The producer is **correct** to ship a `hidden` + * field in an approval request's `payload_json` snapshot, so the UI is not + * compensating for a bad producer when it declines to render it — the UI is + * the *only* place that contract lives, and the Approvals drawer's business + * summary card is default UI. That is why the trim is here and not in + * `@objectstack/plugin-approvals`. + * + * ⛔ Do not extend this to `internal`. They are distinct primitives with + * distinct meanings, and conflating them here would re-introduce the + * serialization semantic the ruling refused. + * + * ## ⚠️ This is a presentation filter, NOT an access control + * + * The server stays the only authority on what a principal may read, and it has + * already answered by the time a payload reaches this page. So an *unanswered* + * metadata read leaves the declaration unknown, and an unknown declaration + * renders the field — the same fail-open direction as `recordReadability` on + * this page, and for the same reason: degrading an approver's decision surface + * on a transient metadata error would break the primary workflow to enforce a + * declaration that was never the security boundary. (Contrast objectui#5553's + * raw-JSON panel, which fails CLOSED — there the measured defect *was* a + * non-holder seeing the panel, so absence of an answer had to deny.) + * + * ## Cost + * + * One `getObjectSchema(objectName)` per distinct object, per mount. The read is + * `GET /api/v1/meta/object/:name` — the same read the record form and detail + * view already perform for any business user — and it lands on the adapter's + * own `MetadataCache` (LRU, 5-minute TTL, in-flight de-duplication), so + * repeated drawer opens on one object cost zero round trips. On top of that + * this hook keeps a per-mount resolved map, so re-renders (the page's 60s + * clock, search typing, a drawer re-opening) cost nothing at all. + * + * Invalidation is therefore: the adapter cache's TTL, an explicit adapter + * `clearCache()` (which the shell issues on a locale switch), or a page + * reload. A `hidden` flag flipped in Studio while this page is open is picked + * up on the next reload — the same staleness `useRecordReadability` accepts, + * and it is a presentation flag, not a grant. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useAdapter } from '@object-ui/app-shell'; + +/** + * Minimal structural view of the metadata source this needs. Declared here + * rather than importing the adapter type so the reader is testable with a + * counting stub — and so it can never reach for anything but a schema read. + * + * `getObjectSchema` is optional on purpose: `DataSource` implementations that + * cannot describe an object simply do not have it, and that is an *unknown* + * answer, not an empty one. + */ +export interface HiddenFieldsSource { + getObjectSchema?(objectName: string): Promise; +} + +/** Nothing declared hidden — also the "we do not know" answer. See the header. */ +const NONE: ReadonlySet = new Set(); + +/** The empty answer, as a shared instance, so consumers can memo on identity. */ +export const NO_HIDDEN_FIELDS = NONE; + +/** + * Field names an object schema declares `hidden: true`. + * + * `fields` arrives in either shape the platform serves: the record shape + * (`{ name: def }`, the `*.object.ts` spec shape) or the array shape + * (`[{ name, ...def }]`, the objectql shape). Both are read; anything else + * yields the empty set. + * + * Strictly `=== true`. A truthy-but-not-true value (a string `'false'`, a `1`) + * is not a declaration this can act on, and guessing at one would hide a field + * the author never asked to hide. + */ +export function hiddenFieldNames(schema: unknown): ReadonlySet { + const fields = (schema as { fields?: unknown } | null | undefined)?.fields; + if (!fields || typeof fields !== 'object') return NONE; + const out = new Set(); + if (Array.isArray(fields)) { + for (const entry of fields) { + if (!entry || typeof entry !== 'object') continue; + const def = entry as Record; + const name = def.name; + if (typeof name === 'string' && name !== '' && def.hidden === true) out.add(name); + } + } else { + for (const [name, def] of Object.entries(fields as Record)) { + if (!def || typeof def !== 'object') continue; + if ((def as Record).hidden === true) out.add(name); + } + } + return out.size > 0 ? out : NONE; +} + +/** + * Read one object's hidden-field declaration. + * + * Never rejects: a source that cannot answer — no `getObjectSchema`, a 404, a + * transport error, a principal without metadata read — resolves to the empty + * set, which is the unknown answer the caller fails open on. + */ +export async function readHiddenFields( + source: HiddenFieldsSource | null | undefined, + objectName: string, +): Promise> { + if (typeof source?.getObjectSchema !== 'function') return NONE; + try { + return hiddenFieldNames(await source.getObjectSchema(objectName)); + } catch { + // Unknown, so the caller renders the field. Deliberately silent: a viewer + // without metadata access to the object hits this on every drawer open, + // and a console error per open would be noise, not a signal. + return NONE; + } +} + +/** + * The hidden-field set for `objectName`, resolved once per mount. + * + * Returns the empty set until the read answers, so the first paint of a drawer + * renders exactly what it renders today and the trim applies as soon as the + * declaration is known. Callers must treat the empty set as "nothing known to + * be hidden" — see the header on failing open. + */ +export function useHiddenFields(objectName: string | null | undefined): ReadonlySet { + const adapter = useAdapter() as HiddenFieldsSource | null | undefined; + const [known, setKnown] = useState>>(() => new Map()); + /** Object names already handed to a read — the "read once" ledger. */ + const attempted = useRef>(new Set()); + + const name = typeof objectName === 'string' && objectName !== '' ? objectName : null; + + useEffect(() => { + if (!adapter || !name || attempted.current.has(name)) return; + attempted.current.add(name); + let cancelled = false; + void readHiddenFields(adapter, name).then((hidden) => { + if (cancelled) return; + setKnown((prev) => { + const next = new Map(prev); + next.set(name, hidden); + return next; + }); + }); + return () => { cancelled = true; }; + }, [adapter, name]); + + return useMemo(() => (name ? known.get(name) ?? NONE : NONE), [known, name]); +}