Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/22537-spec-record-approvals-attachments.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
4 changes: 3 additions & 1 deletion content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand Down Expand Up @@ -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`
Expand Down
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand All @@ -46,23 +46,23 @@ 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

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 |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 59 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **209** |
| **total** | **7** | **210** |

| Bucket | Sites |
|---|---|
Expand Down
181 changes: 181 additions & 0 deletions packages/lint/src/validate-record-approvals-attachments-22537.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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);
});
});
Loading
Loading