diff --git a/.changeset/object-block-props-map.md b/.changeset/object-block-props-map.md new file mode 100644 index 0000000000..6cc3e69327 --- /dev/null +++ b/.changeset/object-block-props-map.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): the `object-*` block family enters `ComponentPropsMap` — a typo'd key inside a data-bound block's `properties` is now caught at authoring time (#7751) + +A misspelled key inside a page component's `properties` used to be accepted in +silence for exactly the block family that carries the platform's data-bound +authoring surface: `object-grid`, `object-metric`, `object-kanban`, +`object-calendar`, `object-form` and `object-master-detail-form` had no entry in +`ComponentPropsMap`, so the ADR-0078 / #5068 authoring gate +(`validate-component-props`) had no schema to dispatch and skipped the node. The +live specimen was #7750: an `object-grid` authored `filters:` (plural) where the +renderer reads `filter` — the wire carried no `$filter` and a personal work +queue listed every row, with a success receipt. + +Per the maintainer's ruling on #7751 (2026-08-12, direction A), the six blocks +above now carry props schemas in `ComponentPropsMap`: + +- **Key sets are derived from the objectui renderers' own read points** + (per-key citations in the section header), never from the designer palette or + registry `inputs` alone — both have published keys with zero read points, and + re-declaring one would recreate the declared-but-inert trap this closes. + Read legacy fallbacks stay honoured (`defaultFilters` on the grid is read; + only the plural `filters` has zero read points anywhere). +- **The typo class is rejected by name with the fix in the message**: + `filters` → `filter` on every block that reads `filter`, and the designer's + dead `groupField` spelling → the `groupBy` the kanban board actually reads. +- **Findings stay warning-tier.** The gate's warning→error upgrade remains + gated on the #5068 inventory; this change does not move that gate. +- **spec↔objectui parity rides the existing `check:react-declaration-parity` + machinery** — the gate now compares the `object-*` map entries against the + registry manifest under the same baseline ratchet (no new gate). +- `object-chart` is deliberately not registered: its authored vocabulary is + two-layered (`chartType` vs `ChartConfigSchema.type`) and not derivable with + the same confidence, so it stays a silently-skipped unregistered type rather + than a partial schema that warns on working keys. + +New exports: `ObjectGridPropsSchema`, `ObjectMetricPropsSchema`, +`ObjectKanbanPropsSchema`, `ObjectCalendarPropsSchema`, `ObjectFormPropsSchema`, +`ObjectMasterDetailFormPropsSchema`. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index e7dcd76277..25322eb801 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1571 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1577 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 148 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1571** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 154 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **198** | **1577** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 148 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 154 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -375,7 +375,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` | | [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` | | [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` | -| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` | +| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` | | [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | | [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` | | [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 5216fb42cc..e46819f972 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -14,8 +14,8 @@ Empty Properties Schema ## TypeScript Usage ```typescript -import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ElementNumberProps, ElementRecordPickerProps, PageContainerProps, RecordHighlightsField, RecordPathProps } from '@objectstack/spec/ui'; +import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordRelatedListProps } from '@objectstack/spec/ui'; +import type { ElementNumberProps, ElementRecordPickerProps, ObjectCalendarProps, ObjectFormProps, ObjectGridProps, ObjectKanbanProps, ObjectMasterDetailFormProps, ObjectMetricProps, PageContainerProps, RecordHighlightsField, RecordPathProps } from '@objectstack/spec/ui'; // Validate data const result = AIChatWindowProps.parse(data); @@ -191,6 +191,196 @@ const result = AIChatWindowProps.parse(data); | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +--- + +## ObjectCalendarProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` | +| **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | +| **filter** | `any` | optional | Base query filter | +| **sort** | `any` | optional | Sort for the fetched events | +| **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch | +| **staticData** | `any[]` | optional | Static inline records | +| **locale** | `string` | optional | Locale override for the calendar chrome | +| **loading** | `boolean` | optional | External loading state (honoured only alongside `data`) | + + +--- + +## ObjectFormProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **recordId** | `string \| number` | optional | Record to load (edit/view modes) | +| **mode** | `Enum<'create' \| 'edit' \| 'view'>` | optional | Form mode | +| **formType** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional | Form presentation | +| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout | +| **columns** | `number` | optional | Field columns in grid layout | +| **fields** | `any[]` | optional | Limit/order the fields shown | +| **customFields** | `any` | optional | Custom field definitions merged into the generated set | +| **sections** | `any[]` | optional | Form sections (`{ label, description?, fields }` — wizard steps / tab panes) | +| **title** | `string \| Record` | optional | Form title | +| **description** | `string \| Record` | optional | Form description (rendered by the drawer/modal presentations) | +| **defaultTab** | `string` | optional | Initially active tab (tabbed) | +| **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed) | +| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard) | +| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard) | +| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split direction (split) | +| **splitSize** | `number` | optional | Split panel size in percent (split) | +| **splitResizable** | `boolean` | optional | Allow resizing the split (split) | +| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer) | +| **drawerWidth** | `string \| number` | optional | Drawer width (drawer) | +| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal) | +| **modalCloseButton** | `boolean` | optional | Show the modal close button (modal) | +| **contentLayout** | `any` | optional | Modal content layout config (modal) | +| **confirmOnDiscard** | `boolean` | optional | Confirm before discarding edits (drawer/modal) | +| **submitText** | `string \| Record` | optional | Submit button label | +| **cancelText** | `string \| Record` | optional | Cancel button label | +| **nextText** | `string \| Record` | optional | Next-step button label (wizard) | +| **prevText** | `string \| Record` | optional | Previous-step button label (wizard) | +| **showSubmit** | `boolean` | optional | Show the submit button | +| **showCancel** | `boolean` | optional | Show the cancel button | +| **showReset** | `boolean` | optional | Show the reset button | +| **submitBehavior** | `any` | optional | What happens after a successful submit (`{ kind: 'thank-you' \| …, title?, message? }`) | +| **successMessage** | `string \| Record` | optional | Toast message on successful submit | +| **resetOnSuccess** | `boolean` | optional | Reset the form after a successful submit | +| **navigateOnSuccess** | `any` | optional | Navigate after a successful submit | +| **readOnly** | `boolean` | optional | Render every field read-only | +| **initialValues** | `Record` | optional | Prefill values (create mode) | +| **initialData** | `Record` | optional | Alternate spelling of `initialValues` the renderer also reads | +| **mobile** | `any` | optional | Mobile presentation overrides | + + +--- + +## ObjectGridProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **label** | `string \| Record` | optional | Grid label — used as the table caption and export file title | +| **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | +| **columns** | `any[]` | optional | Columns: field names or column definition objects | +| **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | +| **filter** | `any` | optional | Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key #7750 misspelled as plural | +| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | +| **sort** | `any` | optional | Initial sort (array of `{ field, order }`) | +| **defaultSort** | `any` | optional | Legacy single-sort fallback (`{ field, order }`), read only when `sort` is absent. Prefer `sort` | +| **pagination** | `any` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging | +| **pageSize** | `number` | optional | Flat page-size shorthand; `pagination.pageSize` wins when both are set | +| **showPagination** | `boolean` | optional | Show the pager (read only when `pagination` is absent) | +| **searchableFields** | `string[]` | optional | Fields the toolbar search queries; a non-empty list enables search | +| **showSearch** | `boolean` | optional | Show the search box (read only when `searchableFields` is absent) | +| **rowHeight** | `any` | optional | Row density mode (e.g. compact / comfortable) | +| **grouping** | `any` | optional | Row grouping config | +| **aggregations** | `any` | optional | Group aggregation config (sum/avg/… per column) | +| **conditionalFormatting** | `any` | optional | Conditional row/cell formatting rules | +| **rowColor** | `any` | optional | Row color rules | +| **selection** | `any` | optional | Selection config (`{ type: none \| single \| multiple }`) | +| **selectable** | `any` | optional | Legacy selection shorthand, read only when `selection` is absent. Prefer `selection` | +| **rowActions** | `any[]` | optional | Per-row action names | +| **bulkActions** | `any[]` | optional | Bulk action names shown on selection | +| **batchActions** | `any[]` | optional | Alternate spelling the renderer reads FIRST (`batchActions ?? bulkActions`) | +| **bulkActionDefs** | `any[]` | optional | Inline bulk-action definitions (full defs, not names) | +| **navigation** | `any` | optional | Row-click navigation config (`{ mode: page \| drawer \| modal \| split \| none }`) | +| **editable** | `boolean` | optional | Enable inline cell editing | +| **singleClickEdit** | `boolean` | optional | Enter cell edit on single click (default true when editable) | +| **resizable** | `boolean` | optional | Allow column resize (read before `resizableColumns`) | +| **resizableColumns** | `boolean` | optional | Alternate spelling of `resizable` (the renderer reads `resizable ?? resizableColumns`) | +| **reorderableColumns** | `boolean` | optional | Allow column drag-reorder | +| **frozenColumns** | `number` | optional | How many leading columns stay frozen (default 1) | +| **showColumnTypeIcons** | `boolean` | optional | Show field-type icons in column headers | +| **exportOptions** | `any` | optional | Export config (`{ formats, streaming }`) | +| **operations** | `any` | optional | Operation toggles (`{ export: false, … }`) | +| **data** | `any[]` | optional | Static inline rows — bypasses the object query | +| **staticData** | `any[]` | optional | Alternate spelling of `data` the renderer also reads | + + +--- + +## ObjectKanbanProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **groupBy** | `string` | optional | Field whose values become the board columns | +| **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | +| **filter** | `any` | optional | Base query filter, handed to the wire `$filter` | +| **data** | `any[]` | optional | Static inline cards — bypasses the object query | +| **cardTitle** | `string` | optional | Field rendered as each card title | +| **titleField** | `string` | optional | Legacy fallback for `cardTitle` (the board reads `cardTitle \|\| titleField`). Prefer `cardTitle` | +| **cardFields** | `string[]` | optional | Fields rendered on each card | +| **swimlaneField** | `string` | optional | Field for horizontal swimlanes (in addition to columns) | +| **grouping** | `any` | optional | View grouping config; its first field is the swimlane fallback | +| **quickAdd** | `boolean` | optional | Show the per-column quick-add affordance | +| **coverImageField** | `string` | optional | Image field rendered as the card cover | +| **conditionalFormatting** | `any` | optional | Card conditional formatting rules | + + +--- + +## ObjectMasterDetailFormProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121) | +| **recordId** | `string \| number` | optional | Parent record to load (edit mode) | +| **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode | +| **formType** | `string` | optional | Parent form presentation | +| **sections** | `any[]` | optional | Parent form sections | +| **fields** | `any[]` | optional | Parent fields shown | +| **details** | `any[]` | optional | Detail collections (`{ title, childObject, addLabel?, columns?, relationshipField? }` — FK and columns auto-derive from child metadata) | +| **title** | `string \| Record` | optional | Form title | +| **submitText** | `string \| Record` | optional | Submit button label | +| **cancelText** | `string \| Record` | optional | Cancel button label | +| **showSubmit** | `boolean` | optional | Show the submit button | +| **initialValues** | `Record` | optional | Prefill values for the parent (create mode) | +| **initialData** | `Record` | optional | Alternate spelling of `initialValues` the renderer also reads | +| **taxRateField** | `string` | optional | Child field holding the per-line tax rate (line-items totals) | + + +--- + +## ObjectMetricProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **label** | `string \| Record` | optional | Metric label | +| **description** | `string \| Record` | optional | Helper text under the value | +| **title** | `string \| Record` | optional | Drill-down panel title; defaults to the metric label | +| **icon** | `string` | optional | Icon name (Lucide) | +| **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant | +| **aggregate** | `any` | optional | Aggregation config (`{ field, function, groupBy? }`) run against the object | +| **filter** | `any` | optional | Filter the aggregation is scoped by | +| **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') | +| **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting | +| **prefix** | `string` | optional | Static prefix before the formatted value | +| **suffix** | `string` | optional | Static suffix after the formatted value | +| **invert** | `boolean` | optional | Display `1 - value` for opposite-signal gauges (compliance/uptime) | +| **variant** | `Enum<'card' \| 'bare'>` | optional | Layout variant | +| **fallbackValue** | `string \| number` | optional | Static value shown when no data source is available | +| **trend** | `any` | optional | Static trend info (`{ value, label, direction }`) | +| **drillDown** | `any` | optional | Click-through drill config — opens the underlying records | +| **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) | + + --- ## PageAccordionProps diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 96306796f2..9cefeb76ac 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 427 | +| Object sites in them | 433 | | Still-open (strip) sites | 132 | | Files carrying at least one | 24 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 162 | 151 | 5 | 0 | 6 | +| `ui/` | 168 | 157 | 5 | 0 | 6 | | `data/` | 153 | 62 | 1 | 0 | 90 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **427** | **289** | **6** | **0** | **132** | +| **total** | **433** | **295** | **6** | **0** | **132** | ## File-level triage — site counts @@ -66,7 +66,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 18 | | `bulk-action.zod.ts` | 3 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 31 | +| `component.zod.ts` | 37 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -77,7 +77,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `theme.zod.ts` | 6 | | `view.zod.ts` | 55 | | `widget.zod.ts` | 1 | -| **total** | **162** | +| **total** | **168** | ### `data/` — sites @@ -156,7 +156,7 @@ over it is here. ### `ui/` — open -**6 strip of 162**, in 4 file(s). +**6 strip of 168**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -164,7 +164,7 @@ over it is here. | `app.zod.ts` | 1 | 18 | | `view.zod.ts` | 3 | 55 | | `widget.zod.ts` | 1 | 1 | -| **total** | **6** | **162** | +| **total** | **6** | **168** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 9cd577db5c..88d4d6d7ab 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -416,7 +416,11 @@ describe('validateComponentProps — value verdicts', () => { * leniency (the maintainer's ruling on #5068). */ describe('validateComponentProps — unregistered types are skipped', () => { - it.each(['record:line_items', 'flex', 'object-metric', 'record:quick_actions'])( + // `object-metric` left this list at #7751 — the `object-*` family is + // registered now. `object-chart` replaces it: deliberately still absent from + // the map (two-vocabulary problem — see component.zod.ts's object-block + // section header), so it is the family's own living proof the skip survives. + it.each(['record:line_items', 'flex', 'object-chart', 'record:quick_actions'])( 'says nothing about `%s`, whatever its props carry', (type) => { const findings = validateComponentProps( @@ -427,6 +431,100 @@ describe('validateComponentProps — unregistered types are skipped', () => { ); }); +/** + * ── #7751: the gate's dispatch now FINDS the `object-*` family ────────────── + * + * No semantic change to this rule — `PROPS_SCHEMAS` is `ComponentPropsMap`, so + * the new entries are reachable by construction. What must be pinned from THIS + * side is behaviour through the real door: + * + * 1. anti-vacuity — the warning FIRES on the #7750 specimen shape (an + * `object-grid` authored with plural `filters`), with the rename named; + * 2. the clean-corpus control — the showcase pages' actual authored nodes + * (copied, not imported) stay finding-free, so registering the family did + * not turn working pages red. + */ +describe('validateComponentProps — object-* blocks are dispatched (#7751)', () => { + it('FIRES on the #7750 specimen: object-grid authored with plural `filters`', () => { + const findings = validateComponentProps( + stackWith([{ + type: 'object-grid', + properties: { + objectName: 'showcase_task', + columns: ['title', 'project', 'status', 'priority', 'due_date'], + filters: [['owner_id', '=', '{current_user_id}']], + }, + }]), + ); + expect(unknownKeys(findings)).toHaveLength(1); + const [f] = unknownKeys(findings); + expect(f.severity).toBe('warning'); + expect(f.path).toBe('pages[0].regions[0].components[0].properties.filters'); + expect(f.where).toBe('page "probe_page" · object-grid'); + expect(f.message).toContain('`filters`'); + expect(f.message).toContain('Did you mean `filters` → `filter`?'); + // Warning tier only — the error upgrade stays gated on the #5068 + // inventory; this card did not move that gate (ruling 2026-08-12). + expect(invalid(findings)).toEqual([]); + }); + + it('stays SILENT on the corrected #7750 node and the corpus metric shapes (clean-corpus control)', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'object-grid', + properties: { + objectName: 'showcase_task', + columns: ['title', 'project', 'status', 'priority', 'due_date'], + filter: [['owner_id', '=', '{current_user_id}']], + }, + }, + { + type: 'object-metric', + properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } }, + }, + { + type: 'object-metric', + properties: { objectName: 'showcase_project', label: 'Projects', colorVariant: 'purple', variant: 'bare', aggregate: { field: 'id', function: 'count' }, format: '0,0' }, + }, + { + type: 'object-form', + properties: { + objectName: 'showcase_project', + mode: 'create', + formType: 'wizard', + showStepIndicator: true, + title: 'Create a Project', + description: 'A three-step wizard.', + sections: [{ label: 'Basics', description: 'Name it.', fields: ['name'] }], + submitBehavior: { kind: 'thank-you', title: 'Project created', message: 'Ready.' }, + }, + }, + { + type: 'object-master-detail-form', + properties: { + objectName: 'showcase_project', + mode: 'create', + formType: 'simple', + submitText: 'Create Project + Tasks', + fields: ['name', 'account'], + details: [{ title: 'Tasks', childObject: 'showcase_task', addLabel: 'Add task' }], + }, + }, + ]), + ); + expect(findings).toEqual([]); + }); + + it("answers the designer's dead `groupField` with the `groupBy` the board reads", () => { + const findings = validateComponentProps( + stackWith([{ type: 'object-kanban', properties: { objectName: 'task', groupField: 'status' } }]), + ); + expect(unknownKeys(findings)).toHaveLength(1); + expect(unknownKeys(findings)[0].message).toContain('Did you mean `groupField` → `groupBy`?'); + }); +}); + /** * Why the registry entry reads the NORMALIZED tier. * diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index c7991c3618..50d002b23e 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -227,7 +227,19 @@ "NotificationSeveritySchema (const)", "NotificationType (type)", "NotificationTypeSchema (const)", + "ObjectCalendarProps (type)", + "ObjectCalendarPropsSchema (const)", + "ObjectFormProps (type)", + "ObjectFormPropsSchema (const)", + "ObjectGridProps (type)", + "ObjectGridPropsSchema (const)", + "ObjectKanbanProps (type)", + "ObjectKanbanPropsSchema (const)", "ObjectListViewSchema (const)", + "ObjectMasterDetailFormProps (type)", + "ObjectMasterDetailFormPropsSchema (const)", + "ObjectMetricProps (type)", + "ObjectMetricPropsSchema (const)", "ObjectNavItem (type)", "ObjectNavItemParsed (type)", "ObjectNavItemSchema (const)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index b5ade541c5..877fb7a005 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -690,6 +690,104 @@ "ui/NavigationContribution:group", "ui/NavigationContribution:items", "ui/NavigationContribution:priority", + "ui/ObjectCalendarProps:calendar", + "ui/ObjectCalendarProps:data", + "ui/ObjectCalendarProps:defaultView", + "ui/ObjectCalendarProps:filter", + "ui/ObjectCalendarProps:loading", + "ui/ObjectCalendarProps:locale", + "ui/ObjectCalendarProps:objectName", + "ui/ObjectCalendarProps:sort", + "ui/ObjectCalendarProps:staticData", + "ui/ObjectFormProps:allowSkip", + "ui/ObjectFormProps:cancelText", + "ui/ObjectFormProps:columns", + "ui/ObjectFormProps:confirmOnDiscard", + "ui/ObjectFormProps:contentLayout", + "ui/ObjectFormProps:customFields", + "ui/ObjectFormProps:defaultTab", + "ui/ObjectFormProps:description", + "ui/ObjectFormProps:drawerSide", + "ui/ObjectFormProps:drawerWidth", + "ui/ObjectFormProps:fields", + "ui/ObjectFormProps:formType", + "ui/ObjectFormProps:initialData", + "ui/ObjectFormProps:initialValues", + "ui/ObjectFormProps:layout", + "ui/ObjectFormProps:mobile", + "ui/ObjectFormProps:modalCloseButton", + "ui/ObjectFormProps:modalSize", + "ui/ObjectFormProps:mode", + "ui/ObjectFormProps:navigateOnSuccess", + "ui/ObjectFormProps:nextText", + "ui/ObjectFormProps:objectName", + "ui/ObjectFormProps:prevText", + "ui/ObjectFormProps:readOnly", + "ui/ObjectFormProps:recordId", + "ui/ObjectFormProps:resetOnSuccess", + "ui/ObjectFormProps:sections", + "ui/ObjectFormProps:showCancel", + "ui/ObjectFormProps:showReset", + "ui/ObjectFormProps:showStepIndicator", + "ui/ObjectFormProps:showSubmit", + "ui/ObjectFormProps:splitDirection", + "ui/ObjectFormProps:splitResizable", + "ui/ObjectFormProps:splitSize", + "ui/ObjectFormProps:submitBehavior", + "ui/ObjectFormProps:submitText", + "ui/ObjectFormProps:successMessage", + "ui/ObjectFormProps:tabPosition", + "ui/ObjectFormProps:title", + "ui/ObjectGridProps:aggregations", + "ui/ObjectGridProps:batchActions", + "ui/ObjectGridProps:bulkActionDefs", + "ui/ObjectGridProps:bulkActions", + "ui/ObjectGridProps:columns", + "ui/ObjectGridProps:conditionalFormatting", + "ui/ObjectGridProps:data", + "ui/ObjectGridProps:defaultFilters", + "ui/ObjectGridProps:defaultSort", + "ui/ObjectGridProps:editable", + "ui/ObjectGridProps:exportOptions", + "ui/ObjectGridProps:fields", + "ui/ObjectGridProps:filter", + "ui/ObjectGridProps:frozenColumns", + "ui/ObjectGridProps:grouping", + "ui/ObjectGridProps:label", + "ui/ObjectGridProps:navigation", + "ui/ObjectGridProps:objectName", + "ui/ObjectGridProps:operations", + "ui/ObjectGridProps:pageSize", + "ui/ObjectGridProps:pagination", + "ui/ObjectGridProps:reorderableColumns", + "ui/ObjectGridProps:resizable", + "ui/ObjectGridProps:resizableColumns", + "ui/ObjectGridProps:rowActions", + "ui/ObjectGridProps:rowColor", + "ui/ObjectGridProps:rowHeight", + "ui/ObjectGridProps:searchableFields", + "ui/ObjectGridProps:selectable", + "ui/ObjectGridProps:selection", + "ui/ObjectGridProps:showColumnTypeIcons", + "ui/ObjectGridProps:showPagination", + "ui/ObjectGridProps:showSearch", + "ui/ObjectGridProps:singleClickEdit", + "ui/ObjectGridProps:sort", + "ui/ObjectGridProps:staticData", + "ui/ObjectGridProps:title", + "ui/ObjectKanbanProps:cardFields", + "ui/ObjectKanbanProps:cardTitle", + "ui/ObjectKanbanProps:columns", + "ui/ObjectKanbanProps:conditionalFormatting", + "ui/ObjectKanbanProps:coverImageField", + "ui/ObjectKanbanProps:data", + "ui/ObjectKanbanProps:filter", + "ui/ObjectKanbanProps:groupBy", + "ui/ObjectKanbanProps:grouping", + "ui/ObjectKanbanProps:objectName", + "ui/ObjectKanbanProps:quickAdd", + "ui/ObjectKanbanProps:swimlaneField", + "ui/ObjectKanbanProps:titleField", "ui/ObjectListView:addRecord", "ui/ObjectListView:allowPrinting", "ui/ObjectListView:appearance", @@ -738,6 +836,38 @@ "ui/ObjectListView:userActions", "ui/ObjectListView:userFilters", "ui/ObjectListView:virtualScroll [RETIRED]", + "ui/ObjectMasterDetailFormProps:cancelText", + "ui/ObjectMasterDetailFormProps:details", + "ui/ObjectMasterDetailFormProps:fields", + "ui/ObjectMasterDetailFormProps:formType", + "ui/ObjectMasterDetailFormProps:initialData", + "ui/ObjectMasterDetailFormProps:initialValues", + "ui/ObjectMasterDetailFormProps:mode", + "ui/ObjectMasterDetailFormProps:objectName", + "ui/ObjectMasterDetailFormProps:recordId", + "ui/ObjectMasterDetailFormProps:sections", + "ui/ObjectMasterDetailFormProps:showSubmit", + "ui/ObjectMasterDetailFormProps:submitText", + "ui/ObjectMasterDetailFormProps:taxRateField", + "ui/ObjectMasterDetailFormProps:title", + "ui/ObjectMetricProps:aggregate", + "ui/ObjectMetricProps:colorVariant", + "ui/ObjectMetricProps:compareTo", + "ui/ObjectMetricProps:currency", + "ui/ObjectMetricProps:description", + "ui/ObjectMetricProps:drillDown", + "ui/ObjectMetricProps:fallbackValue", + "ui/ObjectMetricProps:filter", + "ui/ObjectMetricProps:format", + "ui/ObjectMetricProps:icon", + "ui/ObjectMetricProps:invert", + "ui/ObjectMetricProps:label", + "ui/ObjectMetricProps:objectName", + "ui/ObjectMetricProps:prefix", + "ui/ObjectMetricProps:suffix", + "ui/ObjectMetricProps:title", + "ui/ObjectMetricProps:trend", + "ui/ObjectMetricProps:variant", "ui/ObjectNavItem:badge", "ui/ObjectNavItem:badgeVariant", "ui/ObjectNavItem:filters", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 23e5e2ecb2..cbb13402d7 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -227,7 +227,19 @@ "NotificationSeveritySchema": "src/ui/notification.zod.ts#NotificationSeveritySchema (const)", "NotificationType": "src/ui/notification.zod.ts#NotificationType (type)", "NotificationTypeSchema": "src/ui/notification.zod.ts#NotificationTypeSchema (const)", + "ObjectCalendarProps": "src/ui/component.zod.ts#ObjectCalendarProps (type)", + "ObjectCalendarPropsSchema": "src/ui/component.zod.ts#ObjectCalendarPropsSchema (const)", + "ObjectFormProps": "src/ui/component.zod.ts#ObjectFormProps (type)", + "ObjectFormPropsSchema": "src/ui/component.zod.ts#ObjectFormPropsSchema (const)", + "ObjectGridProps": "src/ui/component.zod.ts#ObjectGridProps (type)", + "ObjectGridPropsSchema": "src/ui/component.zod.ts#ObjectGridPropsSchema (const)", + "ObjectKanbanProps": "src/ui/component.zod.ts#ObjectKanbanProps (type)", + "ObjectKanbanPropsSchema": "src/ui/component.zod.ts#ObjectKanbanPropsSchema (const)", "ObjectListViewSchema": "src/ui/view.zod.ts#ObjectListViewSchema (const)", + "ObjectMasterDetailFormProps": "src/ui/component.zod.ts#ObjectMasterDetailFormProps (type)", + "ObjectMasterDetailFormPropsSchema": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsSchema (const)", + "ObjectMetricProps": "src/ui/component.zod.ts#ObjectMetricProps (type)", + "ObjectMetricPropsSchema": "src/ui/component.zod.ts#ObjectMetricPropsSchema (const)", "ObjectNavItem": "src/ui/app.zod.ts#ObjectNavItem (type)", "ObjectNavItemParsed": "src/ui/app.zod.ts#ObjectNavItemParsed (type)", "ObjectNavItemSchema": "src/ui/app.zod.ts#ObjectNavItemSchema (const)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 0fa5e96117..04d94dc58e 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -92,7 +92,13 @@ "ui/NotificationPosition", "ui/NotificationSeverity", "ui/NotificationType", + "ui/ObjectCalendarProps", + "ui/ObjectFormProps", + "ui/ObjectGridProps", + "ui/ObjectKanbanProps", "ui/ObjectListView", + "ui/ObjectMasterDetailFormProps", + "ui/ObjectMetricProps", "ui/ObjectNavItem", "ui/ObjectUserFilters", "ui/Page", diff --git a/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts b/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts index 497fdf3ffd..a751dc7e2e 100644 --- a/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts +++ b/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts @@ -309,3 +309,53 @@ describe('check:react-declaration-parity — the gate CAN go red (#4690)', () => expect(output).toMatch(/declares no components/); }); }); + +/** + * The SDUI `object-*` blocks ride this gate as of #7751 (maintainer ruling + * 2026-08-12, direction A: the parity burden goes through THIS machinery, no + * new gate). Same comparison, same ratchet, baseline-keyed by the TYPE itself + * (`object-grid`) rather than a PascalCase tag. These tests prove the new + * iteration source with the same fixture harness — including that it can go + * red — because the real manifest only exists at pin-bump time and cannot be + * produced here (#4690's constraint, unchanged). + */ +describe('check:react-declaration-parity — SDUI object-* blocks (#7751)', () => { + it('compares an object-* ComponentPropsMap entry against the manifest (both directions)', { timeout: SPAWN_TIMEOUT_MS }, () => { + // `filter` IS declared by ObjectGridPropsSchema; the fabricated name is not. + const out = run(manifestFor('object-grid', ['objectName', 'filter', 'zzzNotInTheGridSchema'])); + expect(out).toMatch(/# Spec ↔ registry declaration parity \(SDUI object-\* blocks, #7751\)/); + expect(out).toMatch(/object-grid: 2 declared by both/); + expect(out).toMatch(/registry declares, spec does not: .*zzzNotInTheGridSchema/); + // The declared-set surplus (renderer-read keys the palette does not offer) + // is the SOFT signal, present and named: + expect(out).toMatch(/⚠ object-grid: .*spec-only/); + }); + + it('reports an object-* block absent from the manifest as missing', { timeout: SPAWN_TIMEOUT_MS }, () => { + const out = run(manifestFor('something-else', [])); + expect(out).toMatch(/✗ object-grid: NO component in the manifest/); + expect(out).toMatch(/✗ object-metric: NO component in the manifest/); + }); + + it('ratchets a fabricated registry-only input on an object-* block — non-zero, named', { timeout: SPAWN_TIMEOUT_MS }, () => { + const { status, output } = runExit({ + manifest: manifestFor('object-kanban', ['groupBy', 'zzzFabricatedKanbanInput']), + baseline: { blocks: { 'object-kanban': { registryOnly: [], missing: false } } }, + args: ['--strict'], + }); + expect(output).toContain('new registry-only input(s) not in baseline: zzzFabricatedKanbanInput'); + expect(status, output).toBe(1); + }); + + it('a block not yet in the baseline is additive, not a regression (pin-bump onboarding path)', { timeout: SPAWN_TIMEOUT_MS }, () => { + // The committed baseline predates #7751's blocks; the first pin-bump run + // must not go red because coverage GREW. (`--update` then records them.) + const { status, output } = runExit({ + manifest: manifestFor('object-grid', ['objectName', 'columns', 'filter']), + baseline: { blocks: { ObjectForm: { registryOnly: [], missing: true } } }, + args: ['--strict'], + }); + expect(output).toMatch(/no new DECLARATION divergence/); + expect(status, output).toBe(0); + }); +}); diff --git a/packages/spec/scripts/check-react-blocks-declaration-parity.ts b/packages/spec/scripts/check-react-blocks-declaration-parity.ts index bbd04ac621..c9d8cc09ed 100644 --- a/packages/spec/scripts/check-react-blocks-declaration-parity.ts +++ b/packages/spec/scripts/check-react-blocks-declaration-parity.ts @@ -98,6 +98,26 @@ process.env.OS_EAGER_SCHEMAS = '1'; import fs from 'fs'; import { z } from 'zod'; import { REACT_BLOCKS } from '../src/ui/react-blocks'; +import { ComponentPropsMap } from '../src/ui/component.zod'; + +/** + * The SDUI `object-*` page blocks whose props schemas entered + * `ComponentPropsMap` at #7751 (maintainer ruling 2026-08-12, direction A). + * The ruling's third point assigned their spec↔objectui parity burden to THIS + * existing gate rather than a new one, so they ride the same comparison and + * the same baseline ratchet as the react blocks: spec side from the zod + * schema, registry side from the manifest, `registry-only` ratcheted, + * `spec-only` soft (their declared sets are renderer-read supersets of the + * registry `inputs`, so surplus is expected — ADR-0082 §2). + * + * Derived from the map rather than restated, so an `object-*` entry added to + * the spec is covered by this gate the day it lands. Baseline keys use the + * TYPE itself (`object-grid`), which cannot collide with the react blocks' + * PascalCase tags. + */ +const SDUI_OBJECT_BLOCK_TYPES = Object.keys(ComponentPropsMap) + .filter((t) => t.startsWith('object-')) + .sort(); const MANIFEST = process.env.MANIFEST; const FAIL_ON_DIVERGENCE = process.argv.includes('--strict'); @@ -244,6 +264,32 @@ for (const b of REACT_BLOCKS) { if (specOnly.length) console.log(` spec declares, registry does not: ${specOnly.join(', ')}`); if (registryOnly.length) console.log(` registry declares, spec does not: ${registryOnly.join(', ')}`); } +// ── SDUI object-* blocks (#7751) — same comparison, same ratchet ───────────── +console.log('\n# Spec ↔ registry declaration parity (SDUI object-* blocks, #7751)\n'); +for (const type of SDUI_OBJECT_BLOCK_TYPES) { + const schema = (ComponentPropsMap as Record)[type]; + const spec = new Set(specProps(schema)); + const inputs = manifestInputs(manifest, type); + if (inputs === null) { + console.log(`✗ ${type}: NO component in the manifest — not registered or not public.`); + totalMissingComp++; + current[type] = { registryOnly: [], missing: true }; + continue; + } + const inputSet = new Set(inputs); + const specOnly = [...spec].filter((p) => !inputSet.has(p)); + const registryOnly = [...inputSet].filter((p) => !spec.has(p)); + const declaredByBoth = [...spec].filter((p) => inputSet.has(p)); + totalSpecOnly += specOnly.length; + current[type] = { registryOnly: registryOnly.slice().sort(), missing: false }; + const status = specOnly.length === 0 ? '✓' : '⚠'; + console.log( + `${status} ${type}: ${declaredByBoth.length} declared by both, ${specOnly.length} spec-only, ${registryOnly.length} registry-only`, + ); + if (specOnly.length) console.log(` spec declares, registry does not: ${specOnly.join(', ')}`); + if (registryOnly.length) console.log(` registry declares, spec does not: ${registryOnly.join(', ')}`); +} + console.log( `\nSummary: ${totalSpecOnly} spec-only divergences, ${totalMissingComp} blocks missing from the registry.`, ); diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 4c92cf0961..e3bd91370e 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -264,7 +264,7 @@ import type * as M167 from './ui/view.zod.js'; import type * as M170 from './ui/component.zod.js'; // --------------------------------------------------------------------------- -// 825 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 831 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1538,6 +1538,17 @@ export type Iso818 = Assert, export type Iso819 = Assert, z.infer< typeof M170.ElementRecordPickerPropsSchema > >>; export type Iso820 = Assert, z.infer< typeof M170.RecordHighlightsField > >>; export type Iso821 = Assert, z.infer< typeof M170.RecordPathProps > >>; +// The object-* block family (#7751) — deliberately default-free in its first, +// warning-tier step ("the author said nothing" must stay distinguishable from +// "the author asked for the renderer's fallback"), so input === infer holds. +// A default added to any of these six goes red here, and the fix is the ADR's: +// declare the XParsed alias and delete the pin line. +export type Iso839 = Assert, z.infer< typeof M170.ObjectGridPropsSchema > >>; +export type Iso840 = Assert, z.infer< typeof M170.ObjectMetricPropsSchema > >>; +export type Iso841 = Assert, z.infer< typeof M170.ObjectKanbanPropsSchema > >>; +export type Iso842 = Assert, z.infer< typeof M170.ObjectCalendarPropsSchema > >>; +export type Iso843 = Assert, z.infer< typeof M170.ObjectFormPropsSchema > >>; +export type Iso844 = Assert, z.infer< typeof M170.ObjectMasterDetailFormPropsSchema > >>; // ui/page.zod.ts export type Iso822 = Assert, z.infer< typeof M163.PageComponentType > >>; @@ -1625,7 +1636,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 825 isomorphic pins', () => { + it('still declares all 831 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1830,9 +1841,19 @@ describe('ADR-0122 type-alias convention', () => { // anywhere, so the two shapes coincide and ADR-0122 gives it a pin rather // than an `XParsed`. Its id is `Iso838`, the next free one — the ids are // claims about pins, not positions. + // + // 825 -> 831 is #7751 — the object-* block family's six props schemas + // (`ObjectGridPropsSchema` … `ObjectMasterDetailFormPropsSchema`), + // deliberately default-free at the warning tier ("the author said + // nothing" must stay distinguishable from "the author asked for the + // renderer's fallback"), so all six pin isomorphic. Their ids are + // `Iso839`-`Iso844`, the next free ones AFTER #8211's `Iso838` — the + // branch numbered them `Iso838`-`Iso843` while unmerged and renumbered on + // merge, which is legal precisely because the ids are claims about pins, + // not positions: an unmerged claim has been asserted to nobody yet. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert { + const refuse = (schema: { safeParse(v: unknown): any }, value: unknown): string => { + const r = schema.safeParse(value); + expect(r.success).toBe(false); + return r.error.issues.map((i: { message: string }) => i.message).join('\n'); + }; + + it('the six ruled blocks are registered; object-chart deliberately is NOT', () => { + for (const type of [ + 'object-grid', 'object-metric', 'object-kanban', 'object-calendar', + 'object-form', 'object-master-detail-form', + ]) { + expect(ComponentPropsMap[type as keyof typeof ComponentPropsMap], type).toBeDefined(); + } + // Two-vocabulary problem (`chartType` vs ChartConfigSchema `type`; bag + // spread into the generic chart component) — its key set is not derivable + // with this section's confidence, so it stays a SKIPPED unregistered type + // rather than a partial entry that warns on working keys. + expect((ComponentPropsMap as Record)['object-chart']).toBeUndefined(); + }); + + it('the #7750 specimen shape is REJECTED, with the rename in the message: `filters` → `filter`', () => { + const message = refuse(ComponentPropsMap['object-grid'], { + objectName: 'showcase_task', + columns: ['title', 'project', 'status', 'priority', 'due_date'], + filters: [['owner_id', '=', '{current_user_id}']], + }); + expect(message).toContain('`filters`'); + expect(message).toContain('Did you mean `filters` → `filter`?'); + }); + + it('the corrected #7750 node (my-work.page.ts, post-fix) parses GREEN and retains its filter', () => { + const parsed = ComponentPropsMap['object-grid'].parse({ + objectName: 'showcase_task', + columns: ['title', 'project', 'status', 'priority', 'due_date'], + filter: [['owner_id', '=', '{current_user_id}']], + }); + expect(parsed.filter).toEqual([['owner_id', '=', '{current_user_id}']]); + }); + + it('`defaultFilters` stays HONOURED — it is a read legacy fallback, not an inert spelling', () => { + // ObjectGrid.tsx reads it and lowers it to `$filter` when `filter` is + // absent (the routed finding on #7751 verified the read point). Only the + // plural `filters` has zero read points. + const parsed = ComponentPropsMap['object-grid'].parse({ + objectName: 'showcase_task', + defaultFilters: [['status', '=', 'open']], + }); + expect(parsed.defaultFilters).toEqual([['status', '=', 'open']]); + }); + + it('every object-metric node of the showcase corpus parses GREEN (the clean-corpus control)', () => { + // Copies of all three my-work.page.ts metrics + the command-center shape + // (variant/format) — the exact nodes the lint must NOT start warning on. + const nodes = [ + { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } }, + { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: { status: 'in_review' } }, + { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: { health: 'red' } }, + { objectName: 'showcase_task', label: 'Tasks', colorVariant: 'purple', variant: 'bare', aggregate: { field: 'id', function: 'count' }, format: '0,0' }, + ]; + for (const node of nodes) { + const r = ComponentPropsMap['object-metric'].safeParse(node); + expect(r.success, JSON.stringify(node) + '\n' + JSON.stringify((r as any).error?.issues)).toBe(true); + } + }); + + it('the showcase object-form wizard node parses GREEN', () => { + const r = ComponentPropsMap['object-form'].safeParse({ + objectName: 'showcase_project', + mode: 'create', + formType: 'wizard', + showStepIndicator: true, + title: 'Create a Project', + description: 'A three-step wizard — basics, status, then budget & schedule.', + sections: [ + { label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] }, + ], + submitBehavior: { kind: 'thank-you', title: 'Project created', message: 'Ready.' }, + }); + expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); + }); + + it('the showcase object-master-detail-form node parses GREEN', () => { + const r = ComponentPropsMap['object-master-detail-form'].safeParse({ + objectName: 'showcase_project', + mode: 'create', + formType: 'simple', + submitText: 'Create Project + Tasks', + fields: ['name', 'account', 'status', 'health', 'budget', 'end_date'], + details: [{ title: 'Tasks', childObject: 'showcase_task', addLabel: 'Add task' }], + }); + expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); + }); + + it("the designer's dead `groupField` spelling is answered with the `groupBy` the board reads", () => { + // Producer: objectui previews/block-config.ts publishes `groupField` for + // object-kanban; ObjectKanban.tsx reads only `groupBy` (#7973 class). + const message = refuse(ComponentPropsMap['object-kanban'], { objectName: 'task', groupField: 'status' }); + expect(message).toContain('Did you mean `groupField` → `groupBy`?'); + }); + + it('the plural `filters` is rejected by name on every block that reads `filter`', () => { + for (const type of ['object-grid', 'object-metric', 'object-kanban', 'object-calendar'] as const) { + const message = refuse(ComponentPropsMap[type], { filters: [] }); + expect(message, type).toContain('Did you mean `filters` → `filter`?'); + } + }); + + it("object-calendar's flat field spellings get the wrong-layer prescription, not a rename", () => { + // Read as back-compat by getCalendarConfig, emitted by ObjectView/ListView + // handoffs — but the authored spelling is the `calendar` object (one key + // per concept; the `body` → `children` precedent). + const message = refuse(ComponentPropsMap['object-calendar'], { objectName: 'task', startDateField: 'due_date' }); + expect(message).toContain('`calendar`'); + expect(message).toContain('startDateField'); + }); + + it('objectName is OPTIONAL on every entry — the dataSource binding can supply the object (#6953)', () => { + // A required `objectName` would false-flag every node bound through the + // component-level `dataSource`; the lint's required-prop exemption only + // covers the key spelled `object`. Measured, not assumed. + for (const type of [ + 'object-grid', 'object-metric', 'object-kanban', 'object-calendar', + 'object-form', 'object-master-detail-form', + ] as const) { + expect(ComponentPropsMap[type].safeParse({}).success, type).toBe(true); + } + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 47e0bb01d3..86d9db5cbd 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -9,6 +9,10 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // --------------------------------------------------------------------------- // CLOSED AGAINST UNKNOWN KEYS as of #4001 batch A -- all 31 object sites. +// (#7751 then GREW the map by the `object-*` block family -- six entries, +// strict from birth, key sets derived from objectui's renderer read points; +// see the "Object-bound SDUI blocks" section below. The "31"s in this header +// are batch A's own count, kept as the historical measurement they were.) // // SDUI component prop schemas: the declarative shape of every `page:*`, // `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry. @@ -1450,6 +1454,384 @@ export const ElementTextInputPropsSchema = lazySchema(() => strictObject({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); +/** + * ---------------------------------------------------------------------- + * 5. Object-bound SDUI blocks (#7751, maintainer ruling 2026-08-12: direction A) + * ---------------------------------------------------------------------- + * + * The `object-*` family — the platform's data-bound authoring surface — was + * absent from this map, so the #5068 authoring gate had no schema to dispatch + * and SKIPPED every node (the silent skip is a required semantic, not + * leniency — `packages/lint/src/validate-component-props.ts`, module header). + * The live cost was #7750: `object-grid` authored `filters:` (plural) where + * the renderer reads `filter`, the wire carried no `$filter`, and a personal + * work queue listed every row with a success receipt. + * + * KEY SETS ARE DERIVED FROM THE RENDERERS' OWN READ POINTS — measured against + * an objectui checkout at `eb7f586b`, per-block citations below — never from + * the designer palette or the registry `inputs` alone. Both of those have + * published keys with zero read points (`object-grid` `striped`/`bordered`, + * `object-kanban` `groupField` — the objectui#3829 / #7973 class), and + * re-declaring one here would recreate the declared-but-inert trap this + * section exists to close. The registry-declared `inputs` of each block are a + * strict SUBSET of its declared set here, so `check:react-declaration-parity` + * reports zero `registry-only` drift on these blocks; the surplus is + * `spec-only`, the soft signal ADR-0082 §2 expects (the palette is a curated + * subset). That existing gate — not a new one — carries the spec↔objectui + * parity burden going forward (the ruling's third point). + * + * VALUE posture, first step: the #7750 class is a KEY typo, so keys are the + * contract here. Value schemas are deliberately conservative — scalars and + * enums only where renderer, registry and designer agree; `z.unknown()` where + * the value contract still lives in objectui (filter shapes, column defs, + * grouping configs). Tightening values is a later ratchet with its own + * inventory, exactly like the #5068 → #4001-batch-A sequence above. + * + * The warning→error upgrade is untouched by this section: findings on these + * entries are advisory, still gated on the #5068 inventory (the ruling: + * 「warning 层先行,error 升级仍以 inventory 为闸,本裁定不改那个闸」). + * + * Deliberately NOT declared, each with its reason: + * + * - `object-chart` gets NO entry yet. Its authored vocabulary is two-layered + * (`chartType` on the SDUI node vs `type` in `ChartConfigSchema`; corpus + * pages author `dataset`/`dimensions`/`values` that `ObjectChart.tsx` reads + * while the rest of the bag spreads into the generic chart component), so + * its key set is not derivable with the confidence the rest of this section + * meets. A partial entry would warn on working keys — worse than the + * status-quo skip. It stays silently skipped, like every other unregistered + * type. + * - `bind` (read by grid/kanban/chart via objectui's `useDataScope`) is an + * objectui data-scope key, not spec page vocabulary — the spec's binding is + * the component-level `dataSource` (ADR-0089 / #6953). Declaring it here + * would fossilize a non-spec spelling into the contract. + * - Callbacks (`onNavigate`, `onSuccess`, `onCardMove`, `submitHandler`, …) + * and host-injected props (`objectFields`, adapter-shaped `dataSource`) are + * not authorable metadata. + */ + +/** + * What silently happened to a typo'd key on an `object-*` block before #7751 — + * the history line each of this section's rejections carries. Distinct from + * {@link PROPS_HISTORY}: these types were not merely strip-mode, they were + * absent from the map entirely, so even the #5068 gate said nothing. + */ +const objectBlockHistory = (type: string) => + `Until #7751 \`${type}\` had no entry in ComponentPropsMap at all, so the #5068 authoring gate ` + + 'skipped the type: a misspelled key inside `properties` parsed clean, was stored, reached ' + + "objectui's renderer and was ignored there (the #7750 shape — `filters` for `filter` silently " + + 'unfiltered a personal work queue, with a success receipt).'; + +/** + * The plural `filters` never had a read point on any `object-*` renderer — + * `ObjectNavItem.filters` and the react-tier `ListView.filters` declare the + * plural, so an author moving between tiers switches spelling with no signal + * (#7750's actual mechanism). Shared by every block that reads `filter`, so + * the alias cannot drift per block. objectui#4041 retired the plural from the + * `object-grid` registry declaration; this is the spec-side half. + */ +const FILTERS_TO_FILTER = { filters: 'filter' } as const; + +/** + * `object-grid` (objectui `plugin-grid/src/ObjectGrid.tsx` @ `eb7f586b`). + * Read points per key: `objectName` (throughout), `columns`/`fields` (:714-715), + * `filter` (:739, lowered via `toFilterNode` to `$filter`), `defaultFilters` + * (:922 — the LEGACY fallback read only when `filter` is absent; it is read, + * so it stays declared — only the plural `filters` has zero read points), + * `sort` (:741) / `defaultSort` (:943), `pagination`/`pageSize`/`showPagination` + * (:567, :752, :2475-2480), `searchableFields`/`showSearch` (:959, :2484-2486), + * `rowHeight` (:549), `grouping`/`aggregations` (:1076, :1136), `rowColor` + * (:1052), `conditionalFormatting` (:884, :1061), `selection`/`selectable` + * (:2186-2190), `rowActions` (:1927), `batchActions`/`bulkActions` (:2150), + * `bulkActionDefs` (:2165), `navigation` (:1032), `editable`/`singleClickEdit` + * (:2597, :2632), `resizable`/`resizableColumns` (:2598), `reorderableColumns` + * (:2599), `frozenColumns` (:2135), `showColumnTypeIcons` (:1296 …), + * `exportOptions`/`operations` (:1697-1721), `label`/`title` (:1732, :2557), + * `data`/`staticData` (:372, :386). + */ +export const ObjectGridPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-grid`', + history: objectBlockHistory('object-grid'), + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + aliases: FILTERS_TO_FILTER, +}, { + objectName: z.string().optional() + .describe('Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + label: I18nLabelSchema.optional().describe('Grid label — used as the table caption and export file title'), + title: I18nLabelSchema.optional().describe('Fallback for `label` (the renderer reads `label || title`)'), + columns: z.array(z.unknown()).optional() + .describe('Columns: field names or column definition objects'), + fields: z.array(z.unknown()).optional() + .describe('Field list fallback used when `columns` is absent'), + filter: z.unknown().optional() + .describe('Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key #7750 misspelled as plural'), + defaultFilters: z.unknown().optional() + .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), + sort: z.unknown().optional().describe('Initial sort (array of { field, order })'), + defaultSort: z.unknown().optional() + .describe('Legacy single-sort fallback ({ field, order }), read only when `sort` is absent. Prefer `sort`'), + pagination: z.unknown().optional() + .describe('Pagination config ({ pageSize, pageSizeOptions, … }); its presence enables paging'), + pageSize: z.number().optional().describe('Flat page-size shorthand; `pagination.pageSize` wins when both are set'), + showPagination: z.boolean().optional().describe('Show the pager (read only when `pagination` is absent)'), + searchableFields: z.array(z.string()).optional() + .describe('Fields the toolbar search queries; a non-empty list enables search'), + showSearch: z.boolean().optional().describe('Show the search box (read only when `searchableFields` is absent)'), + rowHeight: z.unknown().optional().describe('Row density mode (e.g. compact / comfortable)'), + grouping: z.unknown().optional().describe('Row grouping config'), + aggregations: z.unknown().optional().describe('Group aggregation config (sum/avg/… per column)'), + conditionalFormatting: z.unknown().optional().describe('Conditional row/cell formatting rules'), + rowColor: z.unknown().optional().describe('Row color rules'), + selection: z.unknown().optional().describe('Selection config ({ type: none | single | multiple })'), + selectable: z.unknown().optional().describe('Legacy selection shorthand, read only when `selection` is absent. Prefer `selection`'), + rowActions: z.array(z.unknown()).optional().describe('Per-row action names'), + bulkActions: z.array(z.unknown()).optional().describe('Bulk action names shown on selection'), + batchActions: z.array(z.unknown()).optional().describe('Alternate spelling the renderer reads FIRST (`batchActions ?? bulkActions`)'), + bulkActionDefs: z.array(z.unknown()).optional().describe('Inline bulk-action definitions (full defs, not names)'), + navigation: z.unknown().optional().describe('Row-click navigation config ({ mode: page | drawer | modal | split | none })'), + editable: z.boolean().optional().describe('Enable inline cell editing'), + singleClickEdit: z.boolean().optional().describe('Enter cell edit on single click (default true when editable)'), + resizable: z.boolean().optional().describe('Allow column resize (read before `resizableColumns`)'), + resizableColumns: z.boolean().optional().describe('Alternate spelling of `resizable` (the renderer reads `resizable ?? resizableColumns`)'), + reorderableColumns: z.boolean().optional().describe('Allow column drag-reorder'), + frozenColumns: z.number().optional().describe('How many leading columns stay frozen (default 1)'), + showColumnTypeIcons: z.boolean().optional().describe('Show field-type icons in column headers'), + exportOptions: z.unknown().optional().describe('Export config ({ formats, streaming })'), + operations: z.unknown().optional().describe('Operation toggles ({ export: false, … })'), + data: z.array(z.unknown()).optional().describe('Static inline rows — bypasses the object query'), + staticData: z.array(z.unknown()).optional().describe('Alternate spelling of `data` the renderer also reads'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectGridProps = z.input; + +/** + * `object-metric` (objectui `plugin-dashboard/src/ObjectMetricWidget.tsx` @ + * `eb7f586b`). The widget destructures every prop it reads + * (`ObjectMetricWidgetProps`, :40-110 — the complete read set), and the + * registry shell forwards the authored bag onto it. `columns`/`sort`/`limit` + * are deliberately absent — a metric is one aggregated number; the registry's + * own `ElementDataSourceMapping` comment records that they have no read site. + */ +export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-metric`', + history: objectBlockHistory('object-metric'), + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + aliases: FILTERS_TO_FILTER, +}, { + objectName: z.string().optional() + .describe('Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + label: I18nLabelSchema.optional().describe('Metric label'), + description: I18nLabelSchema.optional().describe('Helper text under the value'), + title: I18nLabelSchema.optional().describe('Drill-down panel title; defaults to the metric label'), + icon: z.string().optional().describe('Icon name (Lucide)'), + colorVariant: z.enum(['default', 'blue', 'teal', 'orange', 'purple', 'success', 'warning', 'danger']) + .optional().describe('Icon container color variant'), + aggregate: z.unknown().optional() + .describe('Aggregation config ({ field, function, groupBy? }) run against the object'), + filter: z.unknown().optional().describe('Filter the aggregation is scoped by'), + format: z.string().optional().describe("Number format pattern (e.g. '0,0', '$0,0', '0%')"), + currency: z.string().optional().describe("ISO currency code (e.g. 'USD') — enables currency formatting"), + prefix: z.string().optional().describe('Static prefix before the formatted value'), + suffix: z.string().optional().describe('Static suffix after the formatted value'), + invert: z.boolean().optional().describe('Display `1 - value` for opposite-signal gauges (compliance/uptime)'), + variant: z.enum(['card', 'bare']).optional().describe('Layout variant'), + fallbackValue: z.union([z.string(), z.number()]).optional() + .describe('Static value shown when no data source is available'), + trend: z.unknown().optional().describe('Static trend info ({ value, label, direction })'), + drillDown: z.unknown().optional().describe('Click-through drill config — opens the underlying records'), + compareTo: z.unknown().optional().describe("Period-over-period comparison ({ kind: 'previousPeriod' | 'previousYear' })"), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectMetricProps = z.input; + +/** + * `object-kanban` (objectui `plugin-kanban/src/ObjectKanban.tsx` + + * `KanbanRenderer` in `plugin-kanban/src/index.tsx` @ `eb7f586b` — the board + * forwards the authored bag on). Read points: `objectName`/`groupBy` + * (throughout), `columns` (:474 — SWIMLANES, `{ id, title }` per `groupBy` + * value or bare strings, NOT a field projection), `filter` (:198, the + * `$filter` handoff), `data` (:217-224), `cardTitle`/`titleField` (:233), + * `cardFields` (:322), `swimlaneField`/`grouping` (:518-519), and via the + * forwarded schema `quickAdd`/`coverImageField`/`conditionalFormatting` + * (`KanbanRenderer`, index.tsx). `groupField` is the DESIGNER's spelling with + * zero read points (#7973 class) — aliased to the `groupBy` the board reads. + */ +export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-kanban`', + history: objectBlockHistory('object-kanban'), + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + aliases: { + ...FILTERS_TO_FILTER, + // The Studio designer's published spelling; no renderer read point — + // the board reads `groupBy` (objectui#3829 / #7973 class). + groupField: 'groupBy', + }, +}, { + objectName: z.string().optional() + .describe('Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + groupBy: z.string().optional().describe('Field whose values become the board columns'), + columns: z.array(z.unknown()).optional() + .describe('Swimlane definitions ({ id, title } per `groupBy` value, or bare value strings) — NOT a field projection'), + filter: z.unknown().optional().describe('Base query filter, handed to the wire `$filter`'), + data: z.array(z.unknown()).optional().describe('Static inline cards — bypasses the object query'), + cardTitle: z.string().optional().describe('Field rendered as each card title'), + titleField: z.string().optional().describe('Legacy fallback for `cardTitle` (the board reads `cardTitle || titleField`). Prefer `cardTitle`'), + cardFields: z.array(z.string()).optional().describe('Fields rendered on each card'), + swimlaneField: z.string().optional().describe('Field for horizontal swimlanes (in addition to columns)'), + grouping: z.unknown().optional().describe('View grouping config; its first field is the swimlane fallback'), + quickAdd: z.boolean().optional().describe('Show the per-column quick-add affordance'), + coverImageField: z.string().optional().describe('Image field rendered as the card cover'), + conditionalFormatting: z.unknown().optional().describe('Card conditional formatting rules'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectKanbanProps = z.input; + +/** + * The flat per-field spellings `ObjectCalendar` keeps reading as a + * backward-compat fallback (`getCalendarConfig`, ObjectCalendar.tsx:150-158) + * and that `ObjectView`/`ListView` emit on their runtime handoff. Read, but + * NOT authorable — one composition key per concept (Prime Directive #12, the + * `body` → `children` precedent on {@link PageContainerProps}): the authored + * spelling is the `calendar` object. + */ +const OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE: readonly KeySetGuidance[] = [ + ...COMPONENT_LEVEL_GUIDANCE, + { + name: 'OBJECT_CALENDAR_FLAT_FIELD_KEYS', + keys: ['startDateField', 'dateField', 'endDateField', 'endField', 'titleField', 'colorField', 'allDayField'], + examples: ['startDateField', 'titleField'], + prescription: + 'Write this as a key of the `calendar` config object instead — `calendar: { startDateField, ' + + 'endDateField, titleField, colorField, allDayField }`. The flat spelling is the runtime handoff ' + + '`ObjectView`/`ListView` emit and a stored-document fallback the renderer keeps reading; it is ' + + 'not a second authorable spelling (one key per concept, Prime Directive #12).', + }, +]; + +/** + * `object-calendar` (objectui `plugin-calendar/src/ObjectCalendar.tsx` + + * registry shell in `plugin-calendar/src/index.tsx` @ `eb7f586b`). Read + * points: `objectName` (throughout), `calendar` (:145 — the canonical config + * object), `defaultView` (:188), `filter`/`sort` (fetch params), `data`/ + * `staticData` (external rows), and via the registry shell's declared host + * hatches `locale` and `loading`. + */ +export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-calendar`', + history: objectBlockHistory('object-calendar'), + guidanceSets: OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE, + aliases: FILTERS_TO_FILTER, +}, { + objectName: z.string().optional() + .describe('Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + calendar: z.unknown().optional() + .describe('Calendar field config: { startDateField, endDateField?, titleField?, colorField?, allDayField? }'), + defaultView: z.enum(['month', 'week', 'day']).optional().describe('Initial view mode'), + filter: z.unknown().optional().describe('Base query filter'), + sort: z.unknown().optional().describe('Sort for the fetched events'), + data: z.array(z.unknown()).optional().describe('Pre-fetched records — skips the internal fetch'), + staticData: z.array(z.unknown()).optional().describe('Static inline records'), + locale: z.string().optional().describe('Locale override for the calendar chrome'), + loading: z.boolean().optional().describe('External loading state (honoured only alongside `data`)'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectCalendarProps = z.input; + +/** + * `object-form` (objectui `plugin-form/src/ObjectForm.tsx` @ `eb7f586b`, plus + * the sub-forms it forwards the whole bag into: `TabbedForm`, `WizardForm`, + * `SplitForm`, `DrawerForm`, `ModalForm` — the declared set is the UNION of + * their `schema.*` reads, which is how `description` earns its place: the + * top-level key is read by the drawer/modal presentations, not by the simple + * form). Enum values are declared only where renderer, registry `inputs` and + * designer palette agree. Callbacks (`onSuccess`, `submitHandler`, …) and the + * controlled `open` state are React-tier props, not authorable metadata — the + * react tier publishes those separately (`react-blocks.ts`). + */ +export const ObjectFormPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-form`', + history: objectBlockHistory('object-form'), + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { + objectName: z.string().optional() + .describe('Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + recordId: z.union([z.string(), z.number()]).optional().describe('Record to load (edit/view modes)'), + mode: z.enum(['create', 'edit', 'view']).optional().describe('Form mode'), + formType: z.enum(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']).optional() + .describe('Form presentation'), + layout: z.enum(['vertical', 'horizontal', 'inline', 'grid']).optional().describe('Field layout'), + columns: z.number().optional().describe('Field columns in grid layout'), + fields: z.array(z.unknown()).optional().describe('Limit/order the fields shown'), + customFields: z.unknown().optional().describe('Custom field definitions merged into the generated set'), + sections: z.array(z.unknown()).optional() + .describe('Form sections ({ label, description?, fields } — wizard steps / tab panes)'), + title: I18nLabelSchema.optional().describe('Form title'), + description: I18nLabelSchema.optional().describe('Form description (rendered by the drawer/modal presentations)'), + defaultTab: z.string().optional().describe('Initially active tab (tabbed)'), + tabPosition: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Tab strip position (tabbed)'), + allowSkip: z.boolean().optional().describe('Allow skipping steps (wizard)'), + showStepIndicator: z.boolean().optional().describe('Show the step indicator (wizard)'), + splitDirection: z.enum(['horizontal', 'vertical']).optional().describe('Split direction (split)'), + splitSize: z.number().optional().describe('Split panel size in percent (split)'), + splitResizable: z.boolean().optional().describe('Allow resizing the split (split)'), + drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Drawer side (drawer)'), + drawerWidth: z.union([z.string(), z.number()]).optional().describe('Drawer width (drawer)'), + modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal size (modal)'), + modalCloseButton: z.boolean().optional().describe('Show the modal close button (modal)'), + contentLayout: z.unknown().optional().describe('Modal content layout config (modal)'), + confirmOnDiscard: z.boolean().optional().describe('Confirm before discarding edits (drawer/modal)'), + submitText: I18nLabelSchema.optional().describe('Submit button label'), + cancelText: I18nLabelSchema.optional().describe('Cancel button label'), + nextText: I18nLabelSchema.optional().describe('Next-step button label (wizard)'), + prevText: I18nLabelSchema.optional().describe('Previous-step button label (wizard)'), + showSubmit: z.boolean().optional().describe('Show the submit button'), + showCancel: z.boolean().optional().describe('Show the cancel button'), + showReset: z.boolean().optional().describe('Show the reset button'), + submitBehavior: z.unknown().optional() + .describe("What happens after a successful submit ({ kind: 'thank-you' | …, title?, message? })"), + successMessage: I18nLabelSchema.optional().describe('Toast message on successful submit'), + resetOnSuccess: z.boolean().optional().describe('Reset the form after a successful submit'), + navigateOnSuccess: z.unknown().optional().describe('Navigate after a successful submit'), + readOnly: z.boolean().optional().describe('Render every field read-only'), + initialValues: z.record(z.string(), z.unknown()).optional().describe('Prefill values (create mode)'), + initialData: z.record(z.string(), z.unknown()).optional().describe('Alternate spelling of `initialValues` the renderer also reads'), + mobile: z.unknown().optional().describe('Mobile presentation overrides'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectFormProps = z.input; + +/** + * `object-master-detail-form` (objectui `plugin-form/src/MasterDetailForm.tsx` + * @ `eb7f586b`). Parent + child line items entered together (ADR-0001). The + * child collections come from `details` — the FK and editable-grid columns + * are auto-derived from the child object's metadata (`deriveMasterDetail.ts`), + * so `details[].columns` is an override, not a requirement. + */ +export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-master-detail-form`', + history: objectBlockHistory('object-master-detail-form'), + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { + objectName: z.string().optional() + .describe('PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121)'), + recordId: z.union([z.string(), z.number()]).optional().describe('Parent record to load (edit mode)'), + mode: z.enum(['create', 'edit']).optional().describe('Form mode'), + formType: z.string().optional().describe('Parent form presentation'), + sections: z.array(z.unknown()).optional().describe('Parent form sections'), + fields: z.array(z.unknown()).optional().describe('Parent fields shown'), + details: z.array(z.unknown()).optional() + .describe('Detail collections ({ title, childObject, addLabel?, columns?, relationshipField? } — FK and columns auto-derive from child metadata)'), + title: I18nLabelSchema.optional().describe('Form title'), + submitText: I18nLabelSchema.optional().describe('Submit button label'), + cancelText: I18nLabelSchema.optional().describe('Cancel button label'), + showSubmit: z.boolean().optional().describe('Show the submit button'), + initialValues: z.record(z.string(), z.unknown()).optional().describe('Prefill values for the parent (create mode)'), + initialData: z.record(z.string(), z.unknown()).optional().describe('Alternate spelling of `initialValues` the renderer also reads'), + taxRateField: z.string().optional().describe('Child field holding the per-line tax rate (line-items totals)'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectMasterDetailFormProps = z.input; + /** * ---------------------------------------------------------------------- * Component Props Map @@ -1507,6 +1889,16 @@ export const ComponentPropsMap = { 'element:form': ElementFormPropsSchema, 'element:record_picker': ElementRecordPickerPropsSchema, 'element:text_input': ElementTextInputPropsSchema, + + // Object-bound SDUI blocks (#7751, maintainer ruling 2026-08-12 direction A). + // Key sets derived from the objectui renderers' own read points — see the + // section header above. `object-chart` is deliberately absent (ditto). + 'object-grid': ObjectGridPropsSchema, + 'object-metric': ObjectMetricPropsSchema, + 'object-kanban': ObjectKanbanPropsSchema, + 'object-calendar': ObjectCalendarPropsSchema, + 'object-form': ObjectFormPropsSchema, + 'object-master-detail-form': ObjectMasterDetailFormPropsSchema, } as const; /**