From 1de07285132d1c388cc5822ca0fa863daae88ce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:00:01 +0000 Subject: [PATCH 1/3] feat(spec): declare `record:approvals` and `record:attachments` with measured strict ComponentPropsMap rows Both are objectui renderers registered inside the reserved `record` namespace with no PageComponentType member and no row, so lint's `component-type-unknown` refused a node the platform's own default-page synthesizer emits. Each becomes an enum member with a strict row measured from the renderer's read points at the `.objectui-sha` pin: `record:attachments` reads no props (`emptyProps`), and `record:approvals` reads only the host's runtime channel (`approvals`, `currentUserId`), refused with prescriptions. Both get a print-refusal reason, and the `record:line_items` row comment no longer calls it the last registered `record:*` renderer without a row. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/src/ui/component.zod.ts | 76 +++++++++++++++++++++++++-- packages/spec/src/ui/page.zod.ts | 9 ++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index bac77ac9c8f..3778738c581 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -408,8 +408,11 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [ * `nav:breadcrumb`, `global:search`, `global:notifications`, * `element:divider`, the three plugin console widgets * `cloud-connection:panel` and `marketplace:installed-list` (#11575) and - * `mcp:connect-agent` (#12344), and `record:approval_decision` (#22472), which - * reads the record context alone. `user:profile` left this list at #14159 — it + * `mcp:connect-agent` (#12344), and `record:approval_decision` (#22472) and + * `record:attachments` (#22537), which read the record context alone. + * (`record:approvals` declares no props either, but its row is its own + * {@link RecordApprovalsProps}, which names the two host-channel keys its + * renderer reads.) `user:profile` left this list at #14159 — it * is not author-placeable at all, so its row refuses the whole bag * ({@link retiredComponentProps}). * @@ -2353,6 +2356,50 @@ export type RecordLineItemsProps = z.input; */ export type RecordLineItemsPropsParsed = z.infer; +/** + * `record:approvals` (#22537): the record page's approval timeline, which + * shows the step the record's approval sits at, who it waits on and what has + * been decided. Measured by the renderer read-point method at the + * `.objectui-sha` pin `47b1f0bb71`. objectui registers it in + * `app-shell/src/views/record-approvals-renderer.tsx:80` (namespace `record`) + * and draws it through `RecordApprovalsPanel`, and `SchemaRenderer` hoists + * `properties` onto the schema that renderer reads. It reads two + * `schema.`s, and both are the HOST's runtime channel, the + * `record:history` `entries` / `loading` class: + * + * - `approvals` (`:61`): the default record page threads its LIVE approvals + * read through it (`RecordDetailView.tsx:2492-2501`, emitted by + * `buildDefaultPageSchema({ approvals })`, `plugin-detail/src/synth/ + * buildDefaultPageSchema.ts:931`), the same read behind the record's + * decision actions. With no host payload the renderer self-fetches the + * record's requests from the record context (`:64-68`). + * - `currentUserId` (`:71`): the host passes the signed-in user's id, which + * the panel's Remind gate falls back to (`RecordApprovalsPanel.tsx:287`). + * Without it the renderer reads the signed-in user itself. + * + * Neither is authorable surface, so the accepted key set is EMPTY and both + * keys are refused with their prescription. Studio's page create never writes + * either: it seeds from `buildDefaultPageSchema(objectDef)` with no options + * (`app-shell/src/views/metadata-admin/anchors.ts:208`), and so never emits + * this node at all. `className` and the designer attributes the renderer + * reads are node-level, never `properties`. + */ +export const RecordApprovalsProps = strictObject({ + surface: 'this `record:approvals`', + history: `\`record:approvals\` declares no props at all. ${PROPS_HISTORY}`, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + guidance: { + approvals: '`approvals` is the HOST\'s data channel, not authorable surface: the default record ' + + 'page passes the approval requests it fetched through it at runtime. Hand-authored requests ' + + 'would show a static, fake approval history that never updates. Omit it: with no host ' + + 'payload the block fetches the record\'s own approval requests.', + currentUserId: '`currentUserId` is the host\'s channel for the signed-in user, not authorable ' + + 'surface: an authored id would decide for every viewer who counts as the submitter. Omit ' + + 'it: the block reads the signed-in user itself.', + }, +}, {}); +export type RecordApprovalsProps = z.input; + export const PageAccordionProps = strictObject({ surface: 'this `page:accordion`', history: PROPS_HISTORY, @@ -9465,9 +9512,11 @@ export const ComponentPropsMap = { 'record:alert': RecordAlertProps, 'record:quick_actions': RecordQuickActionsProps, 'record:history': RecordHistoryProps, - // #21142 — the last registered `record:*` renderer without a row: it sat on - // the string-arm registration ledger instead (`component-type-vocabulary.ts`), - // so the gate skipped its props and a column keyed `field` published green. + // #21142 — a registered `record:*` renderer without a row, though not the + // last one: `record:approvals` and `record:attachments` (#22537, below) were + // registered without rows too. This one sat on the string-arm registration + // ledger instead (`component-type-vocabulary.ts`), so the gate skipped its + // props and a column keyed `field` published green. // Not a `PageComponentType` member: it reaches the type union through the // open string arm, as `element:metadata_viewer` does, and the row is what // makes it known. Key set measured at the `.objectui-sha` pin; see the @@ -9489,6 +9538,23 @@ export const ComponentPropsMap = { // `component-props-unknown-key` finding instead of a silently dropped one. // The panel draws nothing outside a `sys_approval_request` record page. 'record:approval_decision': emptyProps('record:approval_decision'), + // #22537 — the record page's Approvals and Attachments panels. objectui + // registered both inside this reserved namespace with no enum member and no + // row, so `component-type-unknown` refused a node its own default-page + // synthesizer emits, and Studio's page create seeds the Attachments tab onto + // every record page of an `enable.files` object. Both are `PageComponentType` + // members now. Each key set is measured from the renderer's read points at + // the `.objectui-sha` pin `47b1f0bb71`, and both are EMPTY: + // - `record:approvals` reads `approvals` and `currentUserId`, and both are + // the host's runtime channel, refused with a prescription (see the + // schema's own header); + // - `record:attachments` (`app-shell/src/views/ + // record-attachments-renderer.tsx`, registered at `:69`) discards the + // schema node (`schema: _schema`) and draws the record context's + // attachments; `className` and the designer attributes it reads are + // node-level, never `properties`. + 'record:approvals': RecordApprovalsProps, + 'record:attachments': emptyProps('record:attachments'), // Navigation 'app:launcher': emptyProps('app:launcher'), diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index e3221dff63c..fb2013b7945 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -194,6 +194,13 @@ export const PageComponentType = z.enum([ // unregistered until this declaration exists, and plugin-approvals authors // the page after it. Its `ComponentPropsMap` row is an empty strict object. 'record:approval_decision', + // #22537: the record page's Approvals and Attachments panels, which objectui + // registered inside this reserved namespace with no member and no row, so + // `component-type-unknown` refused a node the platform's own default-page + // synthesizer emits. Studio's page create seeds the Attachments tab onto + // every record page of an `enable.files` object. Each has a + // `ComponentPropsMap` row measured from its renderer's read points. + 'record:approvals', 'record:attachments', // Navigation 'app:launcher', 'nav:menu', 'nav:breadcrumb', // Utility — `user:profile` REMOVED (#14159): shell chrome, refused by name @@ -909,6 +916,8 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap = n ['record:alert', 'is a banner each viewer can dismiss, so what prints would depend on who prints it'], ['record:quick_actions', 'is a row of action controls, with nothing to print'], ['record:approval_decision', 'is the decision panel of an approval request: action controls that differ by viewer, beside a progress tally each decision changes, with nothing to print'], + ['record:approvals', 'is the record\'s live approval timeline, with a remind control only the submitter sees, so what prints would depend on who prints it'], + ['record:attachments', 'is a list of attached files with upload, download and delete controls, and the files themselves never reach paper'], ['element:button', 'is an action control, with nothing to print'], ['element:record_picker', 'is an input control, with nothing to print'], ['element:text_input', 'is an input control, with nothing to print'], From 2746bcf0a788146f8503e8c87f1a2c618909d6e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:10:35 +0000 Subject: [PATCH 2/3] test(spec,lint): pin `record:approvals` and `record:attachments` at the rows and the authoring doors The row stays module-private (its accepted key set is empty, like the `emptyProps` rows), and the generated references and the strictness-ledger counts are regenerated for the two new members and the new strict site. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 4 +- .../ui.md | 10 +- ...record-approvals-attachments-22537.test.ts | 181 ++++++++++++++++++ ...record-approvals-attachments-22537.test.ts | 180 +++++++++++++++++ packages/spec/src/ui/component.zod.ts | 7 +- 5 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 packages/lint/src/validate-record-approvals-attachments-22537.test.ts create mode 100644 packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index a19e40bec84..c88eda893c9 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -260,7 +260,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'record:approvals' \| 'record:attachments' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -327,6 +327,8 @@ View filter rule * `record:reference_rail` * `record:history` * `record:approval_decision` +* `record:approvals` +* `record:attachments` * `app:launcher` * `nav:menu` * `nav:breadcrumb` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index bf901dffa21..7a239a2460d 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 209 | 198 | 4 | 0 | 7 | +| `ui/` | 210 | 199 | 4 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 78 | +| `component.zod.ts` | 79 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **209** | +| **total** | **210** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 209**, in 4 file(s). +**7 strip of 210**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **209** | +| **total** | **7** | **210** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/validate-record-approvals-attachments-22537.test.ts b/packages/lint/src/validate-record-approvals-attachments-22537.test.ts new file mode 100644 index 00000000000..e4805b9ef4c --- /dev/null +++ b/packages/lint/src/validate-record-approvals-attachments-22537.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22537 — `record:approvals` and `record:attachments`, the record page's + * Approvals and Attachments panels, through the authoring rules `os validate` / + * `os build` / `os lint` run. objectui registered both inside the spec-reserved + * `record` namespace with no `PageComponentType` member and no + * `ComponentPropsMap` row, so `component-type-unknown` refused the page Studio's + * page create seeds for any `enable.files` object: the seed's Attachments tab + * holds a bare `record:attachments`. The spec-side pins (the rows, the enum + * members, the print classification) live beside the rows in + * `@objectstack/spec`. + */ +import { describe, expect, it } from 'vitest'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; + +import { runAuthoringRules } from './authoring-rules.js'; +import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; +import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js'; + +type AnyRec = Record; + +const OBJECT = { + name: 'invoice', + label: 'Invoice', + enable: { files: true }, + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + }, +}; + +/** + * The page Studio's page create stores for a `type: 'record'` page bound to + * `invoice`: the `regions` and `template` of objectui's + * `buildDefaultPageSchema(objectDef)`, called with no options (objectui + * `app-shell/src/views/metadata-admin/anchors.ts`, `createSeed`). The + * Attachments tab is the synthesizer's `buildDefaultAttachments()`, a bare + * `{ type: 'record:attachments' }` at `components[2].properties.items[1]`. + */ +const seededRecordPage = (extraTabs: unknown[] = []): AnyRec => ({ + name: 'invoice_record', + label: 'Invoice', + type: 'record', + object: 'invoice', + kind: 'full', + template: 'full-width', + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header', properties: { recordChrome: true } }, + { type: 'record:highlights', properties: { fields: ['amount'] } }, + { + type: 'page:tabs', + properties: { + items: [ + { label: 'Details', value: 'details', children: [{ type: 'record:details', properties: { hideFields: ['amount'] } }] }, + { label: 'Attachments', value: 'attachments', children: [{ type: 'record:attachments' }] }, + ...extraTabs, + ], + }, + }, + { type: 'record:discussion' }, + ], + }, + ], +}); + +const approvalsTab = (node: AnyRec) => ({ label: 'Approvals', value: 'approvals', children: [node] }); +/** One more authored tab holding `node`, at `items[2]`. */ +const extraTab = (node: AnyRec) => ({ label: 'Panel', value: 'panel', children: [node] }); + +const stackOf = (page: AnyRec): AnyRec => ({ + manifest: { id: 'com.example.invoices', name: 'invoices', version: '1.0.0', type: 'app' }, + objects: [OBJECT], + pages: [page], +}); + +/** + * The verdict `os validate` reaches on a config: the stack parse, then the + * shared rule pipeline, with an `error` finding as the refusal (the CLI's + * `judgeAuthorTimeRules`, minus the JSX gate and the per-package pass, which a + * config with no `kind: 'jsx'` page and no `packages` never reaches). + */ +const validateVerdict = (page: AnyRec) => { + const normalized = normalizeStackInput(stackOf(page)) as AnyRec; + const parsed = ObjectStackDefinitionSchema.safeParse(normalized); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues.slice(0, 3))).toBe(true); + const findings = runAuthoringRules('validate', { normalized: normalized as never, parsed: parsed.data as never }); + return { + errors: findings.filter((f) => f.severity === 'error'), + componentFindings: findings.filter((f) => f.rule === COMPONENT_TYPE_UNKNOWN || f.rule.startsWith('component-props')), + }; +}; + +const TABS = 'pages[0].regions[0].components[2].properties.items'; + +describe("the page Studio's page create seeds passes the authoring doors", () => { + it('with the Attachments tab the synthesizer emits for an `enable.files` object', () => { + const page = seededRecordPage(); + expect(validateComponentTypes({ pages: [page] })).toEqual([]); + expect(validateComponentProps({ pages: [page] })).toEqual([]); + const verdict = validateVerdict(page); + expect(verdict.errors).toEqual([]); + expect(verdict.componentFindings).toEqual([]); + }); + + it('with an authored Approvals tab placing a bare `record:approvals`', () => { + const page = seededRecordPage([approvalsTab({ type: 'record:approvals' })]); + expect(validateComponentTypes({ pages: [page] })).toEqual([]); + expect(validateComponentProps({ pages: [page] })).toEqual([]); + const verdict = validateVerdict(page); + expect(verdict.errors).toEqual([]); + expect(verdict.componentFindings).toEqual([]); + }); + + it('control: the same seed with a misspelled type in the reserved namespace is refused', () => { + const page = seededRecordPage([approvalsTab({ type: 'record:aprovals' })]); + const verdict = validateVerdict(page); + expect(verdict.errors.map((f) => [f.rule, f.path])).toEqual([ + [COMPONENT_TYPE_UNKNOWN, `${TABS}[2].children[0].type`], + ]); + }); +}); + +describe('a misspelled type offers the declared spelling', () => { + it.each([ + ['record:attachment', 'record:attachments'], + ['record:aprovals', 'record:approvals'], + ])('`%s` is `component-type-unknown`, offering `%s`', (typo, declared) => { + const findings = validateComponentTypes({ pages: [seededRecordPage([extraTab({ type: typo })])] }); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN); + expect(f.severity).toBe('error'); + expect(f.path).toBe(`${TABS}[2].children[0].type`); + expect(f.message).toContain(`\`${typo}\``); + expect(f.message).toContain(`'${declared}'`); + }); +}); + +describe('a prop on either node is a `component-props-unknown-key` finding, naming the key', () => { + it.each([ + ['record:attachments', 'maxFiles'], + ['record:attachments', 'accept'], + ['record:approvals', 'approval'], + ['record:approvals', 'showRemind'], + ])('`%s` › `%s`', (type, key) => { + const page = seededRecordPage([extraTab({ type, properties: { [key]: true } })]); + const findings = validateComponentProps({ pages: [page] }); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); + expect(f.severity).toBe('warning'); + expect(f.path).toBe(`${TABS}[2].children[0].properties.${key}`); + expect(f.where).toBe(`page "invoice_record" · ${type}`); + expect(f.message).toContain(`\`${key}\``); + expect(f.message).toContain(`\`${type}\``); + + // The same finding reaches the shared pipeline the authoring commands run. + expect(validateVerdict(page).componentFindings.map((x) => x.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]); + }); +}); + +describe("`record:approvals` names the host's runtime channel when it is authored", () => { + it.each([ + ['approvals', { available: true, requests: [], pendingRequest: null }, "HOST's data channel"], + ['currentUserId', 'usr_1', 'signed-in user'], + ])('`%s`', (key, value, prescription) => { + const page = seededRecordPage([approvalsTab({ type: 'record:approvals', properties: { [key]: value } })]); + const findings = validateComponentProps({ pages: [page] }); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); + expect(f.path).toBe(`${TABS}[2].children[0].properties.${key}`); + expect(`${f.message}\n${f.hint}`).toContain(prescription); + }); +}); diff --git a/packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts b/packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts new file mode 100644 index 00000000000..6a9b2e8c2ce --- /dev/null +++ b/packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22537 — `record:approvals` and `record:attachments`, the record page's + * Approvals and Attachments panels. objectui registered both inside the + * spec-reserved `record` namespace with no `PageComponentType` member and no + * `ComponentPropsMap` row, so the `component-type-unknown` authoring rule + * refused a node the platform's own default-page synthesizer emits, and + * Studio's page create seeds the Attachments tab onto every record page of an + * `enable.files` object. + * + * Both are declared now, each with a strict row measured from its renderer's + * read points at the `.objectui-sha` pin. Both rows accept no key: + * `record:attachments` reads no props at all, and `record:approvals` reads + * only the host's runtime channel (`approvals`, `currentUserId`), which the row + * refuses with a prescription instead of declaring. The authoring rule ids + * (`component-type-unknown`, `component-props-unknown-key`) are pinned where + * they live, in `@objectstack/lint`. + */ +import { describe, expect, it } from 'vitest'; + +import { ComponentPropsMap } from './component.zod'; +import { + PageComponentSchema, + PageComponentType, + PageSchema, + PRINTABLE_PAGE_COMPONENT_TYPES, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, + RETIRED_PAGE_COMPONENT_TYPES, +} from './page.zod'; +import { + hasReservedComponentNamespace, + isKnownComponentType, + KNOWN_COMPONENT_TYPE_CANDIDATES, + STRING_ARM_REGISTERED_TYPES, +} from './component-type-vocabulary'; + +const TYPES = ['record:approvals', 'record:attachments'] as const; +const row = (type: (typeof TYPES)[number]) => ComponentPropsMap[type]; + +/** + * The page Studio's page create stores for a `type: 'record'` page bound to an + * `enable.files: true` object: the `regions` and `template` of objectui's + * `buildDefaultPageSchema(objectDef)`, called with no options (objectui + * `app-shell/src/views/metadata-admin/anchors.ts`, `createSeed`). The + * Attachments tab is the synthesizer's `buildDefaultAttachments()`, a bare + * `{ type: 'record:attachments' }`. + */ +const seededRecordPage = (extraTabs: unknown[] = []) => ({ + name: 'invoice_record', + label: 'Invoice', + type: 'record', + object: 'invoice', + kind: 'full', + template: 'full-width', + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header', properties: { recordChrome: true } }, + { type: 'record:highlights', properties: { fields: ['amount'] } }, + { + type: 'page:tabs', + properties: { + items: [ + { label: 'Details', value: 'details', children: [{ type: 'record:details', properties: { hideFields: ['amount'] } }] }, + { label: 'Attachments', value: 'attachments', children: [{ type: 'record:attachments' }] }, + ...extraTabs, + ], + }, + }, + { type: 'record:discussion' }, + ], + }, + ], +}); + +/** The issues of a failed safeParse, after asserting it failed. */ +const issuesOf = (result: { success: boolean; error?: { issues: Array<{ code: string; message: string; keys?: string[] }> } }) => { + expect(result.success).toBe(false); + return result.error!.issues; +}; + +describe('both types are declared', () => { + it.each(TYPES)('`%s` is a `PageComponentType` member with a `ComponentPropsMap` row', (type) => { + expect(PageComponentType.options).toContain(type); + expect(PageComponentType.safeParse(type).success).toBe(true); + expect(Object.keys(ComponentPropsMap)).toContain(type); + expect(row(type)).toBeDefined(); + }); + + it.each(TYPES)('`%s` is known through the declaration, not through the string-arm ledger', (type) => { + expect(STRING_ARM_REGISTERED_TYPES).not.toContain(type); + expect(RETIRED_PAGE_COMPONENT_TYPES.has(type)).toBe(false); + expect(hasReservedComponentNamespace(type)).toBe(true); + expect(isKnownComponentType(type)).toBe(true); + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain(type); + }); + + it('a misspelling inside the reserved `record` namespace stays unknown', () => { + for (const typo of ['record:approval', 'record:aprovals', 'record:attachment', 'record:attachements']) { + expect(hasReservedComponentNamespace(typo), typo).toBe(true); + expect(isKnownComponentType(typo), typo).toBe(false); + } + }); +}); + +describe('each row is a strict object that accepts no key', () => { + it.each(TYPES)('`%s` declares no key', (type) => { + expect(Object.keys(row(type).shape)).toEqual([]); + }); + + it.each(TYPES)('`%s` accepts the empty bag at every door: the row, the node, the page', (type) => { + expect(row(type).safeParse({}).success).toBe(true); + expect(PageComponentSchema.safeParse({ type }).success).toBe(true); + expect(PageComponentSchema.safeParse({ type, properties: {} }).success).toBe(true); + // Node-level keys stay on the node: both renderers read `className` off the + // node, never out of `properties`. + expect(PageComponentSchema.safeParse({ type, id: 'panel', className: 'mt-4' }).success).toBe(true); + }); + + it.each(TYPES)('`%s` refuses a misspelled or invented prop, naming the component and the key', (type) => { + for (const key of ['approval', 'currentUser', 'maxFiles', 'title']) { + const issues = issuesOf(row(type).safeParse({ [key]: true })); + expect(issues, `${type}.${key}`).toHaveLength(1); + const [issue] = issues; + expect(issue.code, `${type}.${key}`).toBe('unrecognized_keys'); + expect(issue.keys, `${type}.${key}`).toEqual([key]); + expect(issue.message, `${type}.${key}`).toContain(`\`${type}\``); + expect(issue.message, `${type}.${key}`).toContain(key); + } + }); + + it.each(TYPES)('`%s` answers a node-level key written inside `properties` with the node prescription', (type) => { + const [issue] = issuesOf(row(type).safeParse({ className: 'mt-4' })); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('component NODE'); + }); +}); + +describe("`record:approvals` refuses the host's runtime channel with its prescription", () => { + it('`approvals`, the approval requests the default record page fetched', () => { + const [issue] = issuesOf(row('record:approvals').safeParse({ + approvals: { available: true, requests: [], pendingRequest: null }, + })); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.keys).toEqual(['approvals']); + expect(issue.message).toContain("HOST's data channel"); + expect(issue.message).toContain('Omit it'); + }); + + it('`currentUserId`, the signed-in user the host passes', () => { + const [issue] = issuesOf(row('record:approvals').safeParse({ currentUserId: 'usr_1' })); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.keys).toEqual(['currentUserId']); + expect(issue.message).toContain('signed-in user'); + expect(issue.message).toContain('Omit it'); + }); +}); + +describe("the page Studio's page create seeds parses", () => { + it('with the Attachments tab the synthesizer emits for an `enable.files` object', () => { + expect(PageSchema.safeParse(seededRecordPage()).success).toBe(true); + }); + + it('with an authored Approvals tab placing a bare `record:approvals`', () => { + const page = seededRecordPage([ + { label: 'Approvals', value: 'approvals', children: [{ type: 'record:approvals' }] }, + ]); + expect(PageSchema.safeParse(page).success).toBe(true); + }); +}); + +describe('the print classification answers for both', () => { + it.each(TYPES)('`%s` is refused inside a print page with its own reason, and is not printable', (type) => { + expect(PRINTABLE_PAGE_COMPONENT_TYPES.has(type)).toBe(false); + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.get(type)).toMatch(/^is /); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 3778738c581..cb5f71151ed 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2383,8 +2383,12 @@ export type RecordLineItemsPropsParsed = z.infer; * (`app-shell/src/views/metadata-admin/anchors.ts:208`), and so never emits * this node at all. `className` and the designer attributes the renderer * reads are node-level, never `properties`. + * + * Not exported, like the {@link emptyProps} rows it matches in contract: with + * no accepted key there is no author-state type to name, and the row is + * reached through `ComponentPropsMap['record:approvals']`. */ -export const RecordApprovalsProps = strictObject({ +const RecordApprovalsProps = strictObject({ surface: 'this `record:approvals`', history: `\`record:approvals\` declares no props at all. ${PROPS_HISTORY}`, guidanceSets: COMPONENT_LEVEL_GUIDANCE, @@ -2398,7 +2402,6 @@ export const RecordApprovalsProps = strictObject({ + 'it: the block reads the signed-in user itself.', }, }, {}); -export type RecordApprovalsProps = z.input; export const PageAccordionProps = strictObject({ surface: 'this `page:accordion`', From a611fcd31afc6426fe2649e84126df8c0d247346 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:16:12 +0000 Subject: [PATCH 3/3] chore(changeset): `@objectstack/spec` minor for `record:approvals` and `record:attachments` Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../22537-spec-record-approvals-attachments.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/22537-spec-record-approvals-attachments.md diff --git a/.changeset/22537-spec-record-approvals-attachments.md b/.changeset/22537-spec-record-approvals-attachments.md new file mode 100644 index 00000000000..f6627ba01e3 --- /dev/null +++ b/.changeset/22537-spec-record-approvals-attachments.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +--- + +`record:approvals` and `record:attachments` are declared page component types — the record page's Approvals and Attachments panels — each with a `ComponentPropsMap` row that accepts no props + +Clause-②: yes (widening) + +- **Two new `PageComponentType` members:** `record:approvals` (the record's approval timeline: the step its approval sits at, who it waits on, what has been decided) and `record:attachments` (the record's attached files, with upload, download and delete). objectui already renders both, and its default record page places them: the Attachments tab on every record page of an `enable.files: true` object, the Approvals tab on a record that has approval requests. +- **The page Studio's page create seeds now passes `os validate`.** Before, `record` was a reserved namespace with neither type declared, so `os validate` / `os build` / `os lint` refused the seeded page of any `enable.files` object with `component-type-unknown` on its `record:attachments` node. The metadata save door did not refuse it and still does not. +- **Both rows are strict and accept no key.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is reported as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. +- **`record:approvals` refuses the runtime channel it reads, with the fix.** Its renderer reads `approvals` (the approval requests the default record page fetched) and `currentUserId` (the signed-in user) from the node, and both are filled by the host at runtime. An authored `approvals` would show a static approval history that never updates, and an authored `currentUserId` would decide for every viewer who counts as the submitter. Both are refused with a message that says to omit them: the block fetches the record's own approval requests and reads the signed-in user itself. +- **A misspelling is refused.** `record:attachment`, `record:aprovals` and other near spellings are `component-type-unknown` errors that offer the declared spelling. +- **Not printable.** Inside a page that declares `print`, each type is refused with its own reason, in place of the generic "not in the printable block subset" one. +- **Consumers.** A `kind: 'react'` page that names `` or `` is now refused by `react-block-needs-record-context`, as every `record:*` block is: these blocks read the record context a record page mounts, and a react page mounts none. A consumer that derives coverage from `PageComponentType.options` or the keys of `ComponentPropsMap` — a designer palette, a renderer registry — sees two more members to classify.