Skip to content

Commit 5014dea

Browse files
committed
test(spec,lint): pin record:approval_decision at the row, the vocabulary and the authoring doors; regenerate page reference
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
1 parent 9c23135 commit 5014dea

3 files changed

Lines changed: 93 additions & 5 deletions

File tree

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

Lines changed: 2 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' \| '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' \| '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. |
@@ -326,6 +326,7 @@ View filter rule
326326
* `record:quick_actions`
327327
* `record:reference_rail`
328328
* `record:history`
329+
* `record:approval_decision`
329330
* `app:launcher`
330331
* `nav:menu`
331332
* `nav:breadcrumb`
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22472 — `record:approval_decision`, the approval decision panel, through the
5+
* authoring rules `os validate` / `os build` / `os lint` run. The ruling that
6+
* asked for the declaration (objectui#12045, 6079807016, letter 乙) gave its
7+
* reason as exactly these two doors: a type with no `ComponentPropsMap` row
8+
* passes any props bag, so a misspelled property drops in silence, and a type
9+
* the vocabulary does not declare inside the reserved `record` namespace is
10+
* refused outright. The spec-side pins (the row, the enum member, the print
11+
* classification) live beside the row in `@objectstack/spec`.
12+
*/
13+
import { describe, expect, it } from 'vitest';
14+
15+
import { runAuthoringRules } from './authoring-rules.js';
16+
import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js';
17+
import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js';
18+
19+
type AnyRec = Record<string, unknown>;
20+
21+
/** The request page plugin-approvals authors: one record page, one region. */
22+
const requestPage = (components: unknown[]): AnyRec => ({
23+
pages: [
24+
{
25+
name: 'approval_request_detail',
26+
label: 'Approval request',
27+
type: 'record',
28+
object: 'sys_approval_request',
29+
regions: [{ name: 'main', components }],
30+
},
31+
],
32+
});
33+
34+
const ourRules = (stack: AnyRec) =>
35+
runAuthoringRules('validate', { normalized: stack as never }).filter(
36+
(f) => f.rule.startsWith('component-props') || f.rule === COMPONENT_TYPE_UNKNOWN,
37+
);
38+
39+
describe('a node with no props passes both doors', () => {
40+
it.each([
41+
['no `properties` at all', { type: 'record:approval_decision' }],
42+
['an empty `properties`', { type: 'record:approval_decision', properties: {} }],
43+
['node-level keys only', { type: 'record:approval_decision', id: 'decision', className: 'mt-4' }],
44+
])('%s', (_label, node) => {
45+
expect(validateComponentProps(requestPage([node]))).toEqual([]);
46+
expect(validateComponentTypes(requestPage([node]))).toEqual([]);
47+
expect(ourRules(requestPage([node]))).toEqual([]);
48+
});
49+
});
50+
51+
describe('any authored key is a `component-props-unknown-key` finding, naming the key', () => {
52+
it.each(['showProgress', 'actions', 'requestId'])('`%s`', (key) => {
53+
const stack = requestPage([{ type: 'record:approval_decision', properties: { [key]: true } }]);
54+
const findings = validateComponentProps(stack);
55+
expect(findings).toHaveLength(1);
56+
const [f] = findings;
57+
expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
58+
expect(f.severity).toBe('warning');
59+
expect(f.path).toBe(`pages[0].regions[0].components[0].properties.${key}`);
60+
expect(f.where).toBe('page "approval_request_detail" · record:approval_decision');
61+
expect(f.message).toContain(`\`${key}\``);
62+
expect(f.message).toContain('`record:approval_decision`');
63+
64+
// The same finding reaches the shared pipeline the authoring commands run.
65+
const viaPipeline = ourRules(stack);
66+
expect(viaPipeline.map((x) => x.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]);
67+
});
68+
});
69+
70+
describe('a misspelled type in the reserved `record` namespace is refused', () => {
71+
it('`record:approval_decison` is `component-type-unknown`, offering the declared spelling', () => {
72+
const stack = requestPage([{ type: 'record:approval_decison' }]);
73+
const findings = validateComponentTypes(stack);
74+
expect(findings).toHaveLength(1);
75+
const [f] = findings;
76+
expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN);
77+
expect(f.severity).toBe('error');
78+
expect(f.path).toBe('pages[0].regions[0].components[0].type');
79+
expect(f.message).toContain('`record:approval_decison`');
80+
expect(f.message).toContain("'record:approval_decision'");
81+
82+
expect(ourRules(stack).map((x) => x.rule)).toEqual([COMPONENT_TYPE_UNKNOWN]);
83+
});
84+
85+
it('control: the declared spelling in the same position is not refused', () => {
86+
expect(validateComponentTypes(requestPage([{ type: 'record:approval_decision' }]))).toEqual([]);
87+
});
88+
});

‎packages/spec/src/ui/component-record-approval-decision-22472.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import {
3333
STRING_ARM_REGISTERED_TYPES,
3434
} from './component-type-vocabulary';
3535

36-
const TYPE = 'record:approval_decision';
37-
const ROW = () => (ComponentPropsMap as Record<string, (typeof ComponentPropsMap)['nav:menu']>)[TYPE];
36+
const TYPE = 'record:approval_decision' as const;
37+
const ROW = () => ComponentPropsMap[TYPE];
3838

3939
const requestPage = (component: Record<string, unknown>) => ({
4040
name: 'approval_request_detail',
@@ -73,8 +73,7 @@ describe('the type is declared, spec first', () => {
7373

7474
describe('the row is an empty strict object', () => {
7575
it('declares no key', () => {
76-
const shape = (ROW() as unknown as { shape: Record<string, unknown> }).shape;
77-
expect(Object.keys(shape)).toEqual([]);
76+
expect(Object.keys(ROW().shape)).toEqual([]);
7877
});
7978

8079
it('accepts the empty bag at every door: the row, the node, the page', () => {

0 commit comments

Comments
 (0)