Skip to content

Commit dab6bf4

Browse files
feat(spec): declare record:approvals and record:attachments with measured strict ComponentPropsMap rows (#22595)
Fixes #22537 Clause-②: yes (widening) ## What this declares `record:approvals` and `record:attachments` are now declared page component types in `@objectstack/spec`. They are 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 that the platform's own default-page synthesizer emits. This follows the triage remedy (`6089894781`, unlocked in `6091349791`): measured rows in the spec, not a rename. | File | Change | | :--- | :--- | | `packages/spec/src/ui/page.zod.ts` | both join `PageComponentType` beside `record:approval_decision`; `PRINT_REFUSED_PAGE_COMPONENT_TYPES` gets a reason for each (the print pin requires every vocabulary member to be classified) | | `packages/spec/src/ui/component.zod.ts` | `'record:attachments': emptyProps(...)`; `'record:approvals': RecordApprovalsProps`, a module-private strict row that accepts no key and refuses the two host-channel keys with a prescription; the `record:line_items` row comment no longer calls it "the last registered `record:*` renderer without a row"; the `emptyProps` docblock lists `record:attachments` | | `content/docs/references/ui/page.mdx` | regenerated (`gen:docs`): the two enum members | | `docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` | regenerated (`gen:strictness-ledger`): one more strict site in `component.zod.ts` | | `packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts` | pins at the rows, the node, the page, the vocabulary and the print classification | | `packages/lint/src/validate-record-approvals-attachments-22537.test.ts` | pins at the two authoring rules and at the verdict `os validate` reaches on Studio's seeded page | | `.changeset/22537-spec-record-approvals-attachments.md` | `@objectstack/spec` `minor`, `Clause-②: yes (widening)` | ## The doors on `main`, measured before the change The triage asked for the save door first. Reading on `main` at `d6c37919c7` (this branch's base), with a page shaped like the one Studio's page create seeds for an `enable.files: true` object. That page is the `regions` and `template` of objectui's `buildDefaultPageSchema(objectDef)` called with no options (`app-shell/src/views/metadata-admin/anchors.ts:196-216` at the `.objectui-sha` pin `47b1f0bb71`), so its Attachments tab holds a bare `record:attachments`. Studio's seed never emits `record:approvals`: the synthesizer emits it only when the runtime host passes its live approvals read. | Page | Save door (`saveMetaItem`, what `PUT /api/v1/meta/page/:name` lands in) | `os validate` verdict | | :--- | :--- | :--- | | A. Studio's seed (`record:attachments`) | admitted, stored `active` | **refused**: `component-type-unknown` at `pages[0].regions[0].components[2].properties.items[1].children[0].type` | | B. A plus an authored Approvals tab (bare `record:approvals`) | admitted | **refused**: `component-type-unknown` on both nodes | | C. A plus the runtime host's Approvals node (with `approvals` and `currentUserId`) | admitted | **refused**: `component-type-unknown` on both nodes | | control: A with `print: {}` | refused, 422 `INVALID_METADATA` (`print-page-block-unprintable`) | | | control: a `user:profile` node | refused, 422 `INVALID_METADATA` (the parse) | | - **The save door does not refuse the platform's own page, on `main` or on this branch.** `validateComponentTypes` is registered `CLI_ONLY` (`packages/lint/src/authoring-rules.ts`), held off the runtime door until a false-refusal budget over stored tenant rows is measured. The rules the page door ran were `validatePresetComparands` and `validatePrintPageBlocks`. The two controls show the harness's gate was live. - **`os validate` does refuse it.** That is the door this change opens. - How it was measured: two throwaway in-process tests, not committed. The save door used the stub engine of `protocol.runtime-authoring-gate.test.ts` driving `ObjectStackProtocolImplementation.saveMetaItem`. `os validate` used `normalizeStackInput`, then `ObjectStackDefinitionSchema.safeParse`, then `runAuthoringRules('validate', …)`, with an `error` finding as the refusal. That is the CLI's `judgeAuthorTimeRules` without the JSX gate and the per-package pass, which this config never reaches. The `os` binary itself was not run, because its 60-package build closure was not built. ## After the change, same pages, same doors | Page | Save door | `os validate` | | :--- | :--- | :--- | | A | admitted | **passes**: 0 errors, 0 component findings | | B | admitted | **passes**: 0 errors, 0 component findings | | C | admitted | passes with 2 advisory `component-props-unknown-key` warnings (`approvals`, `currentUserId`) | Inside a `print` page, `record:attachments` is now refused with its own reason. Before, it got the generic "not in the printable block subset" reason. ## The row measurement (objectui at the `.objectui-sha` pin `47b1f0bb71`) - **`record:attachments`** (`app-shell/src/views/record-attachments-renderer.tsx`, registered `:69`): the renderer discards the schema node (`schema: _schema`, `:45`) and reads the record context, the auth user and `className`. **The row is `emptyProps`.** - **`record:approvals`** (`app-shell/src/views/record-approvals-renderer.tsx`, registered `:80`). `SchemaRenderer` hoists `properties` onto the schema the renderer reads. The renderer reads two keys: - `schema.approvals` (`:61`): the default record page threads its live approvals read through it (`RecordDetailView.tsx:2492-2501`, via `buildDefaultPageSchema({ approvals })`, `buildDefaultPageSchema.ts:931`). With no payload, the renderer self-fetches (`:64-68`). - `schema.currentUserId` (`:71`): the host passes `user?.id`, and the panel's Remind gate falls back to it (`RecordApprovalsPanel.tsx:287`). - **Both keys are the host's runtime channel.** That is the class `record:history`'s `entries` / `loading` and `record:quick_actions`' inline `actions` belong to. That precedent refuses such keys with a prescription and does not declare them. So the row accepts no key, and it refuses `approvals` ("a static, fake approval history that never updates") and `currentUserId` ("would decide for every viewer who counts as the submitter"), each with the fix: omit it. - **This departs from the dispatch's working rule** ("the row is the union the renderer reads"). The departure is deliberate and is raised in the report. Declaring the two keys would bless exactly the trap the precedent closes. - **The synthesizer emits no key that the renderer does not read.** `RecordApprovalsProps` is not exported. Its accepted key set is empty, like the `emptyProps` rows, so there is no author-state type to publish. The row is reached as `ComponentPropsMap['record:approvals']`. The API surface is unchanged (`check:api-surface` green, no `api-surface/` diff). ## The false sentence, and the vocabulary claims - `component.zod.ts`'s `record:line_items` row comment said "the last registered `record:*` renderer without a row". This PR corrects it. - No sentence in `component-type-vocabulary.ts` claims that the map covers every measured registration. "EMPTY since #21142" is a statement about the ledger, and it stays true. The `KNOWN_COMPONENT_TYPES` docblock ("a superset of the enum by exactly the measured string-arm registrations that DID get a row") is not made false: both types are enum members now. That file is untouched. ## Pins - **spec** (`component-record-approvals-attachments-22537.test.ts`): - each type is an enum member with a row, known through the declaration rather than the ledger; - near misspellings stay unknown; - each row declares no key, admits `{}` at the row, node and page doors, and refuses an invented prop by name; - a node-level key inside `properties` gets the node prescription; - `approvals` and `currentUserId` are refused with their prescriptions; - Studio's seeded page parses; - both carry a print-refusal reason. - **lint** (`validate-record-approvals-attachments-22537.test.ts`): - Studio's seeded page, alone and with an authored Approvals tab, draws no `component-type-unknown` and no props finding, and reaches a zero-error `os validate` verdict; - control: the same seed with `record:aprovals` is refused at the exact path; - `record:attachment` and `record:aprovals` offer the declared spelling; - an invented prop on either node is a `component-props-unknown-key` warning naming the key; - the two host-channel keys carry their prescription. ## Verification, at `e492d331d4` (this branch's head, after one merge of `origin/main` at `96e4be4829`) **Suites and typechecks.** Every one of these ran on this head. Spec and lint were run whole, and the three consumer suites were run at their envelope test: | Package | Test | Typecheck | | :--- | :--- | :--- | | `@objectstack/spec` | 639 files, 19033 passed, 1 todo | `tsc` plus `check:test-typecheck` OK | | `@objectstack/lint` | 134 files, 6109 passed | `tsc` plus `check:test-typecheck` OK | | `@objectstack/mcp` | `canonical-expression-envelopes.test.ts` 11 passed | OK | | `@objectstack/platform-objects` | `pages/canonical-expression-envelopes.test.ts` 20 passed | OK | | `@objectstack/cloud-connection` | `canonical-expression-envelopes.test.ts` 16 passed | OK, after building its dependency closure | Those are the packages that read `PageComponentType` or `ComponentPropsMap`. The three consumer packages reach them only through `@objectstack/lint`'s envelope audit and a `Page` type import. **Generated artifacts.** `pnpm --filter @objectstack/spec check:generated`: "All 15 generated artifacts are up to date". The `--fix` round regenerated only `page.mdx` and the strictness-ledger counts. **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 109 families. 108 exited 0. The reconciliation (`--ran`, exit-annotated) reads "109 derived famil(ies) accounted for — 108 run, 1 NOT-MEASURED". - NOT MEASURED: `check:dual-build-cjs-loads`. Reason: the dispatch forbids the whole-workspace build it loads. - 34 of the gates first ran while this seat's own `cloud-connection` closure build was rewriting `dist/`. Six exited 3 (`PREREQUISITE NOT MET`) and two exited 1 (`check:generated`, `check:dts-closure`). All 34 were re-run once the build had finished, and all exited 0. The record carries the re-run codes. **Artifact rosters.** The 48 roster commands outside the PR-context guards all exited 0, the 14 checker-health `--self-test` rows among them. The three PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) are run against this PR once it exists, and the report carries their readings. **ESLint, narrowed and proved.** CI runs the whole repo. - ① The population, read from `eslint.config.mjs`: every `**/*.{ts,…}` file outside `NEVER_LINTED`, and the four touched TypeScript files are inside it. - ② `--format json` reports 4 files, 0 errors and 0 warnings. - ③ The config never enables type-aware linting (no `parserOptions.project`, stated in its own header). So this diff cannot move the verdict on any file it does not touch. **Not measured locally:** - the `os` binary itself (its 60-package closure was not built; its verdict function was measured instead); - the CI-only jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core); - the workspace-wide typecheck lane. ## Acceptance notes - **A derived consequence:** a `kind: 'react'` page that names the RecordApprovals or RecordAttachments JSX tag is now refused by `react-block-needs-record-context`. `RECORD_CONTEXT_BLOCK_TAGS` derives from the map's `record:*` keys. - Measured on this branch: both tags draw an `error`, as RecordApprovalDecision did from #22472 on. - Neither tag was ever reachable on a react page. objectui builds that scope from `ComponentRegistry.getPublicConfigs()` (`components/src/renderers/layout/react-page.tsx:90`), and neither type is in the curated public-block list (`core/src/registry/public-blocks.ts`). So the new refusal names a page that never rendered. - The changeset says so. It is not declared `(narrowing)`: no page that worked is refused. The report asks the seat to confirm. - **Save door unchanged.** `component-type-unknown` stays off the save door (`CLI_ONLY`, its crossing is its own rollout). This change neither moves it nor needs to. - `@objectstack/lint` has no changeset. Its behaviour changes only by reading the spec's map, the same as #22472. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba95607 commit dab6bf4

7 files changed

Lines changed: 467 additions & 11 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`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
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **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.
12+
- **`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.
13+
- **A misspelling is refused.** `record:attachment`, `record:aprovals` and other near spellings are `component-type-unknown` errors that offer the declared spelling.
14+
- **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.
15+
- **Consumers.** A `kind: 'react'` page that names `<RecordApprovals>` or `<RecordAttachments>` 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.

‎content/docs/references/ui/page.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ View filter rule
260260

261261
| Property | Type | Required | Description |
262262
| :--- | :--- | :--- | :--- |
263-
| **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. |
263+
| **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. |
264264
| **id** | `string` | optional | Unique instance ID |
265265
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
266266
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
@@ -327,6 +327,8 @@ View filter rule
327327
* `record:reference_rail`
328328
* `record:history`
329329
* `record:approval_decision`
330+
* `record:approvals`
331+
* `record:attachments`
330332
* `app:launcher`
331333
* `nav:menu`
332334
* `nav:breadcrumb`

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 209 | 198 | 4 | 0 | 7 |
24+
| `ui/` | 210 | 199 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3636
| `app.zod.ts` | 19 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
39-
| `component.zod.ts` | 78 |
39+
| `component.zod.ts` | 79 |
4040
| `dashboard.zod.ts` | 11 |
4141
| `dataset.zod.ts` | 4 |
4242
| `i18n.zod.ts` | 1 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 59 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **209** |
49+
| **total** | **210** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 209**, in 4 file(s).
57+
**7 strip of 210**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 59 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **209** |
65+
| **total** | **7** | **210** |
6666

6767
| Bucket | Sites |
6868
|---|---|
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22537 — `record:approvals` and `record:attachments`, the record page's
5+
* Approvals and Attachments panels, through the authoring rules `os validate` /
6+
* `os build` / `os lint` run. objectui registered both inside the spec-reserved
7+
* `record` namespace with no `PageComponentType` member and no
8+
* `ComponentPropsMap` row, so `component-type-unknown` refused the page Studio's
9+
* page create seeds for any `enable.files` object: the seed's Attachments tab
10+
* holds a bare `record:attachments`. The spec-side pins (the rows, the enum
11+
* members, the print classification) live beside the rows in
12+
* `@objectstack/spec`.
13+
*/
14+
import { describe, expect, it } from 'vitest';
15+
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
16+
17+
import { runAuthoringRules } from './authoring-rules.js';
18+
import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js';
19+
import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js';
20+
21+
type AnyRec = Record<string, unknown>;
22+
23+
const OBJECT = {
24+
name: 'invoice',
25+
label: 'Invoice',
26+
enable: { files: true },
27+
sharingModel: 'private',
28+
fields: {
29+
name: { type: 'text', label: 'Name' },
30+
amount: { type: 'number', label: 'Amount' },
31+
},
32+
};
33+
34+
/**
35+
* The page Studio's page create stores for a `type: 'record'` page bound to
36+
* `invoice`: the `regions` and `template` of objectui's
37+
* `buildDefaultPageSchema(objectDef)`, called with no options (objectui
38+
* `app-shell/src/views/metadata-admin/anchors.ts`, `createSeed`). The
39+
* Attachments tab is the synthesizer's `buildDefaultAttachments()`, a bare
40+
* `{ type: 'record:attachments' }` at `components[2].properties.items[1]`.
41+
*/
42+
const seededRecordPage = (extraTabs: unknown[] = []): AnyRec => ({
43+
name: 'invoice_record',
44+
label: 'Invoice',
45+
type: 'record',
46+
object: 'invoice',
47+
kind: 'full',
48+
template: 'full-width',
49+
regions: [
50+
{
51+
name: 'main',
52+
width: 'full',
53+
components: [
54+
{ type: 'page:header', properties: { recordChrome: true } },
55+
{ type: 'record:highlights', properties: { fields: ['amount'] } },
56+
{
57+
type: 'page:tabs',
58+
properties: {
59+
items: [
60+
{ label: 'Details', value: 'details', children: [{ type: 'record:details', properties: { hideFields: ['amount'] } }] },
61+
{ label: 'Attachments', value: 'attachments', children: [{ type: 'record:attachments' }] },
62+
...extraTabs,
63+
],
64+
},
65+
},
66+
{ type: 'record:discussion' },
67+
],
68+
},
69+
],
70+
});
71+
72+
const approvalsTab = (node: AnyRec) => ({ label: 'Approvals', value: 'approvals', children: [node] });
73+
/** One more authored tab holding `node`, at `items[2]`. */
74+
const extraTab = (node: AnyRec) => ({ label: 'Panel', value: 'panel', children: [node] });
75+
76+
const stackOf = (page: AnyRec): AnyRec => ({
77+
manifest: { id: 'com.example.invoices', name: 'invoices', version: '1.0.0', type: 'app' },
78+
objects: [OBJECT],
79+
pages: [page],
80+
});
81+
82+
/**
83+
* The verdict `os validate` reaches on a config: the stack parse, then the
84+
* shared rule pipeline, with an `error` finding as the refusal (the CLI's
85+
* `judgeAuthorTimeRules`, minus the JSX gate and the per-package pass, which a
86+
* config with no `kind: 'jsx'` page and no `packages` never reaches).
87+
*/
88+
const validateVerdict = (page: AnyRec) => {
89+
const normalized = normalizeStackInput(stackOf(page)) as AnyRec;
90+
const parsed = ObjectStackDefinitionSchema.safeParse(normalized);
91+
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues.slice(0, 3))).toBe(true);
92+
const findings = runAuthoringRules('validate', { normalized: normalized as never, parsed: parsed.data as never });
93+
return {
94+
errors: findings.filter((f) => f.severity === 'error'),
95+
componentFindings: findings.filter((f) => f.rule === COMPONENT_TYPE_UNKNOWN || f.rule.startsWith('component-props')),
96+
};
97+
};
98+
99+
const TABS = 'pages[0].regions[0].components[2].properties.items';
100+
101+
describe("the page Studio's page create seeds passes the authoring doors", () => {
102+
it('with the Attachments tab the synthesizer emits for an `enable.files` object', () => {
103+
const page = seededRecordPage();
104+
expect(validateComponentTypes({ pages: [page] })).toEqual([]);
105+
expect(validateComponentProps({ pages: [page] })).toEqual([]);
106+
const verdict = validateVerdict(page);
107+
expect(verdict.errors).toEqual([]);
108+
expect(verdict.componentFindings).toEqual([]);
109+
});
110+
111+
it('with an authored Approvals tab placing a bare `record:approvals`', () => {
112+
const page = seededRecordPage([approvalsTab({ type: 'record:approvals' })]);
113+
expect(validateComponentTypes({ pages: [page] })).toEqual([]);
114+
expect(validateComponentProps({ pages: [page] })).toEqual([]);
115+
const verdict = validateVerdict(page);
116+
expect(verdict.errors).toEqual([]);
117+
expect(verdict.componentFindings).toEqual([]);
118+
});
119+
120+
it('control: the same seed with a misspelled type in the reserved namespace is refused', () => {
121+
const page = seededRecordPage([approvalsTab({ type: 'record:aprovals' })]);
122+
const verdict = validateVerdict(page);
123+
expect(verdict.errors.map((f) => [f.rule, f.path])).toEqual([
124+
[COMPONENT_TYPE_UNKNOWN, `${TABS}[2].children[0].type`],
125+
]);
126+
});
127+
});
128+
129+
describe('a misspelled type offers the declared spelling', () => {
130+
it.each([
131+
['record:attachment', 'record:attachments'],
132+
['record:aprovals', 'record:approvals'],
133+
])('`%s` is `component-type-unknown`, offering `%s`', (typo, declared) => {
134+
const findings = validateComponentTypes({ pages: [seededRecordPage([extraTab({ type: typo })])] });
135+
expect(findings).toHaveLength(1);
136+
const [f] = findings;
137+
expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN);
138+
expect(f.severity).toBe('error');
139+
expect(f.path).toBe(`${TABS}[2].children[0].type`);
140+
expect(f.message).toContain(`\`${typo}\``);
141+
expect(f.message).toContain(`'${declared}'`);
142+
});
143+
});
144+
145+
describe('a prop on either node is a `component-props-unknown-key` finding, naming the key', () => {
146+
it.each([
147+
['record:attachments', 'maxFiles'],
148+
['record:attachments', 'accept'],
149+
['record:approvals', 'approval'],
150+
['record:approvals', 'showRemind'],
151+
])('`%s` › `%s`', (type, key) => {
152+
const page = seededRecordPage([extraTab({ type, properties: { [key]: true } })]);
153+
const findings = validateComponentProps({ pages: [page] });
154+
expect(findings).toHaveLength(1);
155+
const [f] = findings;
156+
expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
157+
expect(f.severity).toBe('warning');
158+
expect(f.path).toBe(`${TABS}[2].children[0].properties.${key}`);
159+
expect(f.where).toBe(`page "invoice_record" · ${type}`);
160+
expect(f.message).toContain(`\`${key}\``);
161+
expect(f.message).toContain(`\`${type}\``);
162+
163+
// The same finding reaches the shared pipeline the authoring commands run.
164+
expect(validateVerdict(page).componentFindings.map((x) => x.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]);
165+
});
166+
});
167+
168+
describe("`record:approvals` names the host's runtime channel when it is authored", () => {
169+
it.each([
170+
['approvals', { available: true, requests: [], pendingRequest: null }, "HOST's data channel"],
171+
['currentUserId', 'usr_1', 'signed-in user'],
172+
])('`%s`', (key, value, prescription) => {
173+
const page = seededRecordPage([approvalsTab({ type: 'record:approvals', properties: { [key]: value } })]);
174+
const findings = validateComponentProps({ pages: [page] });
175+
expect(findings).toHaveLength(1);
176+
const [f] = findings;
177+
expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
178+
expect(f.path).toBe(`${TABS}[2].children[0].properties.${key}`);
179+
expect(`${f.message}\n${f.hint}`).toContain(prescription);
180+
});
181+
});

0 commit comments

Comments
 (0)