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]); +}