diff --git a/.changeset/18305-component-props-map-map-gantt-tree.md b/.changeset/18305-component-props-map-map-gantt-tree.md new file mode 100644 index 00000000000..db0facd1356 --- /dev/null +++ b/.changeset/18305-component-props-map-map-gantt-tree.md @@ -0,0 +1,23 @@ +--- +"@objectstack/spec": minor +--- + +`ComponentPropsMap` declares `object-map`, `object-gantt` and `object-tree` — the three object-bound SDUI blocks #7751 enumerated past — with each row's key set derived from the objectui renderer's own read points (#18305). + +**Clause-②: yes (widening)** — three new declared rows on a published surface, so the accept set a consumer writes against grows. Nothing previously admitted is refused, and nothing is retired. Contract-review tier. + +Until now the `object-*` family carried six rows, `object-chart` carried a written note saying its key set is not derivable with this section's confidence, and these three carried neither: they were not ruled out, they were never measured. The cost was the one #7751 exists to remove — the `@objectstack/lint` props gate had no schema to dispatch on, so every authored key inside `properties` on one of these nodes parsed clean, stored, shipped and was ignored by the renderer with a success receipt. It also left objectui's own `@object-ui/types` mirror standing in as the authority for `object-map.data` and `object-gantt.data`, and left `object-tree`'s record-source read undeclared on every published face (objectui#8348, PR objectui#9234). Executing the ruling 「8348 以协议为准」 (decision batch #83, 2026-09-08) and batch #136 item 3 (Q1-C). + +Key sets measured from `plugin-map/src/ObjectMap.tsx`, `plugin-gantt/src/ObjectGantt.tsx` and `plugin-tree/src/ObjectTree.tsx` at the `.objectui-sha` pin `53ded82b`, with per-key read-point citations in each schema's header: + +- **`object-map`** — `objectName`, `data`, `staticData`, `filter`, `sort`, `map`, `mapStyle`, `navigation`, `enableClustering`. +- **`object-gantt`** — the same record-source and query keys, plus `gantt`, `navigation`, `label`, `skipWeekends`, `holidays`, `persistLayout`, `viewName`, `markers`, `criticalPath`, `showBaselines`, `readOnly`, `mobileReadOnly`. +- **`object-tree`** — `objectName`, `data`, `staticData`, `filter`, `tree`, `navigation`. No `sort`: this renderer's fetch carries `$filter`, `$top` and `$expand` and no `$orderby`, so a `sort` door here would publish a key with no read site. + +Three things the derivation decided rather than assumed, each pinned: + +- **`data` is the `ViewData` object arm on all three**, because rung 1 of the shared record-source ladder returns the authored value verbatim as a `ViewData`. For map and gantt that agrees with objectui's mirror — verified from the read points first and read back as a check, never as the source. For **`object-tree` it does not**: the mirror declares no `data`, no `staticData`, no `filter` and no `navigation` at all, while the renderer reads all four (`data` on two sites). The row follows the read points, which is what 「以协议为准」 resolving for this block means. +- **The flat top-level config spellings stay unauthorable.** `ObjectView` / `ListView` build these nodes by spreading `options.map` / `options.gantt` / `options.tree`'s CONTENTS at the top level; that is an internal transport form, not a second authoring surface (maintainer ruling objectui#5018, 2026-08-17, inherited by objectui#6469). Writing one now gets a wrong-layer prescription naming the config block instead of a bare unknown-key refusal — the channel `object-calendar` already uses for its own flat field spellings. +- **`filter` and `sort` are the family's one orthography from birth** — `ViewFilterRule[]` and `SortItem[]`, not the `z.unknown()` the original six carried before #15449 and objectui#8221 pulled them back. + +Nothing about the parse of a page changes: `PageComponentSchema.type` already accepted all three through its open string arm, and it still does. What changes is that an authored props bag on one of them is now judged instead of skipped. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ecb4fb95a3e..b401bd9b17f 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 — 1522 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1525 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) | 10 | 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) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **193** | **1522** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **193** | **1525** | 14 protocol modules | --- @@ -359,7 +359,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 153 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 156 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -370,7 +370,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`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | +| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | | [`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` | | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e66744ba6d2..e352c0cb3c5 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -12,8 +12,8 @@ description: Component protocol schemas ## TypeScript Usage ```typescript -import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; -import type { ElementNumberProps, ElementRecordPickerProps, ObjectCalendarProps, ObjectFormProps, ObjectGridProps, ObjectKanbanProps, ObjectMasterDetailFormProps, ObjectMetricProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; +import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGanttPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, ObjectTreePropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; +import type { ElementNumberProps, ElementRecordPickerProps, ObjectCalendarProps, ObjectFormProps, ObjectGanttProps, ObjectGridProps, ObjectKanbanProps, ObjectMapProps, ObjectMasterDetailFormProps, ObjectMetricProps, ObjectTreeProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; // Validate data const result = AIChatWindowProps.parse(data); @@ -387,6 +387,116 @@ Sort field and direction pair | **mobile** | `any` | optional | Mobile presentation overrides | +--- + +## ObjectGanttProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this gantt binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | +| **navigation** | `any` | optional | Task-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| none }`); renderer default `drawer` | +| **label** | `string \| Record` | optional | Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object's own label | +| **skipWeekends** | `boolean` | optional | Measure duration and auto-schedule math in WORKING days, skipping Saturdays and Sundays | +| **holidays** | `string[]` | optional | Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math | +| **persistLayout** | `boolean` | optional | Opt OUT of layout and filter-chip persistence — only an explicit `false` disables it; the storage key is `objectName:viewName` | +| **viewName** | `string` | optional | Layout-persistence scope, the second half of the `objectName:viewName` storage key (renderer default `'default'`) | +| **markers** | `any[]` | optional | Extra vertical reference lines drawn like the Today marker (`{ date, label?, color? }`) | +| **criticalPath** | `boolean` | optional | Seed the critical-path highlight ON; the toolbar toggle stays available either way | +| **showBaselines** | `boolean` | optional | Render the planned-vs-actual baseline bars — ON unless an explicit `false` disables it | +| **readOnly** | `boolean` | optional | Disable every write path on this gantt and lock the record drawer | +| **mobileReadOnly** | `boolean` | optional | Auto read-only on narrow viewports — ON unless an explicit `false` disables it | + +### Nested Shape: `ObjectGanttProps.data[provider='object']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'object'` | ✅ | | +| **object** | `string` | ✅ | Target object name | + +### Nested Shape: `ObjectGanttProps.data[provider='api']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'api'` | ✅ | | +| **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | +| **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | + +### Nested Shape: `ObjectGanttProps.data[provider='value']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'value'` | ✅ | | +| **items** | `any[]` | ✅ | Static data array | + +### Nested Shape: `ObjectGanttProps.data[provider='schema']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'schema'` | ✅ | | +| **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | +| **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | + +### Nested Shape: `ObjectGanttProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + +### Nested Shape: `ObjectGanttProps.sort[number]` + +Sort field and direction pair + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to sort by | +| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | + +### Nested Shape: `ObjectGanttProps.gantt` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **startDateField** | `string` | ✅ | Field providing the task start date | +| **endDateField** | `string` | ✅ | Field providing the task end date | +| **titleField** | `string` | ✅ | Field displayed as the task title | +| **progressField** | `string` | optional | Field providing the task completion percentage | +| **dependenciesField** | `string` | optional | Field listing the task's predecessor (dependency) record ids | +| **colorField** | `string` | optional | Field to derive each bar color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else a semantic color token derived from the value | +| **parentField** | `string` | optional | Field holding the parent task id (builds the summary → step tree) | +| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone | +| **baselineStartField** | `string` | optional | Baseline (planned) start field | +| **baselineEndField** | `string` | optional | Baseline (planned) end field | +| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) | +| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline | +| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) | +| **effortField** | `string` | optional | Per-task load units (resource view; default 1) | +| **capacity** | `number` | optional | Per-resource capacity ceiling; loads above this flag overload | +| **tooltipFields** | `(string \| { field: string; label?: string })[]` | optional | Fields to surface in the hover tooltip, in display order | +| **quickFilters** | `{ field: string; label?: string; options?: (string \| object)[] }[]` | optional | Multi-select filter dropdowns rendered above the chart | +| **autoZoomToFilter** | `boolean` | optional | When true (default), filtering zooms the range to the filtered tasks | +| **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | +| **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | +| **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | +| **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | +| **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | +| **timeZone** | `string` | optional | Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; persisted data stays real instants. An invalid name falls back to the browser zone with a console warning | +| **exportFileName** | `string` | optional | Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended | +| **interactions** | `{ move?: boolean; resize?: boolean; progress?: boolean; link?: boolean }` | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow | +| **timeSegments** | `{ dayStart?: string; bands: object[]; showMidnight?: boolean }` | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted | + + --- ## ObjectGridProps @@ -517,6 +627,74 @@ View filter rule | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +--- + +## ObjectMapProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this map binds to — the THIRD record source `getDataConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **staticData** | `any[]` | optional | Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | +| **map** | `any` | optional | Map field config, the author face: `{ latitudeField, longitudeField, locationField?, titleField?, descriptionField?, zoom?, center?, style? }`. Taken WHOLE when present — the flat top-level spelling beside it is ignored | +| **mapStyle** | `string` | optional | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record | +| **navigation** | `any` | optional | Marker-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| none }`) | +| **enableClustering** | `boolean` | optional | Group nearby markers into clusters. Absent, the renderer clusters only above 100 markers | + +### Nested Shape: `ObjectMapProps.data[provider='object']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'object'` | ✅ | | +| **object** | `string` | ✅ | Target object name | + +### Nested Shape: `ObjectMapProps.data[provider='api']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'api'` | ✅ | | +| **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | +| **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | + +### Nested Shape: `ObjectMapProps.data[provider='value']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'value'` | ✅ | | +| **items** | `any[]` | ✅ | Static data array | + +### Nested Shape: `ObjectMapProps.data[provider='schema']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'schema'` | ✅ | | +| **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | +| **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | + +### Nested Shape: `ObjectMapProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + +### Nested Shape: `ObjectMapProps.sort[number]` + +Sort field and direction pair + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to sort by | +| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | + + --- ## ObjectMasterDetailFormProps @@ -579,6 +757,71 @@ View filter rule | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +--- + +## ObjectTreeProps + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectName** | `string` | optional | Object this tree binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because either of the first two rungs resolves the source without it: `data` can name the object itself (`{ provider: 'object', object }`) and `staticData` needs none. ⚠️ NOT supplied by the component-level `dataSource` binding the sibling blocks name — this renderer registers no such gate | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | +| **navigation** | `any` | optional | Row-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| none }`) | + +### Nested Shape: `ObjectTreeProps.data[provider='object']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'object'` | ✅ | | +| **object** | `string` | ✅ | Target object name | + +### Nested Shape: `ObjectTreeProps.data[provider='api']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'api'` | ✅ | | +| **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | +| **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | + +### Nested Shape: `ObjectTreeProps.data[provider='value']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'value'` | ✅ | | +| **items** | `any[]` | ✅ | Static data array | + +### Nested Shape: `ObjectTreeProps.data[provider='schema']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `'schema'` | ✅ | | +| **schemaId** | `string` | ✅ | Schema identifier — typically the metadata type name | +| **schema** | `Record` | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. | + +### Nested Shape: `ObjectTreeProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + +### Nested Shape: `ObjectTreeProps.tree` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **parentField** | `string` | optional | Single-parent pointer field (auto-detected from the object schema when omitted) | +| **labelField** | `string` | optional | Field rendered indented in the first column (defaults to "name") | +| **fields** | `string[]` | optional | Additional fields rendered as flat columns alongside the label | +| **defaultExpandedDepth** | `integer` | optional | Initial expansion depth (0 = roots only; omit = expand all) | + + --- ## 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 adffc71d3f5..10b21e9c004 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 | 444 | +| Object sites in them | 447 | | Still-open (strip) sites | 124 | | Files carrying at least one | 22 | @@ -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/` | 172 | 162 | 3 | 0 | 7 | +| `ui/` | 175 | 165 | 3 | 0 | 7 | | `data/` | 157 | 76 | 1 | 0 | 80 | | `automation/` | 68 | 43 | 0 | 1 | 24 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **444** | **315** | **4** | **1** | **124** | +| **total** | **447** | **318** | **4** | **1** | **124** | ## 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` | 43 | +| `component.zod.ts` | 46 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 61 | | `widget.zod.ts` | 1 | -| **total** | **172** | +| **total** | **175** | ### `data/` — sites @@ -155,7 +155,7 @@ over it is here. ### `ui/` — open -**7 strip of 172**, in 4 file(s). +**7 strip of 175**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -163,7 +163,7 @@ over it is here. | `app.zod.ts` | 1 | 18 | | `view.zod.ts` | 4 | 61 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **172** | +| **total** | **7** | **175** | | Bucket | Sites | |---|---| diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 03220c00d39..d1068f2496b 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -256,6 +256,9 @@ "ObjectCalendarPropsSchema (const)", "ObjectFormProps (type)", "ObjectFormPropsSchema (const)", + "ObjectGanttProps (type)", + "ObjectGanttPropsParsed (type)", + "ObjectGanttPropsSchema (const)", "ObjectGridProps (type)", "ObjectGridPropsParsed (type)", "ObjectGridPropsSchema (const)", @@ -263,6 +266,9 @@ "ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema (const)", "ObjectListViewSchema (const)", + "ObjectMapProps (type)", + "ObjectMapPropsParsed (type)", + "ObjectMapPropsSchema (const)", "ObjectMasterDetailFormProps (type)", "ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps (type)", @@ -271,6 +277,9 @@ "ObjectNavItem (type)", "ObjectNavItemParsed (type)", "ObjectNavItemSchema (const)", + "ObjectTreeProps (type)", + "ObjectTreePropsParsed (type)", + "ObjectTreePropsSchema (const)", "ObjectUserFiltersSchema (const)", "PAGE_TYPE_ROADMAP (const)", "Page (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index c3153874810..4048f035433 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -734,6 +734,23 @@ "ui/ObjectFormProps:successMessage", "ui/ObjectFormProps:tabPosition", "ui/ObjectFormProps:title", + "ui/ObjectGanttProps:criticalPath", + "ui/ObjectGanttProps:data", + "ui/ObjectGanttProps:filter", + "ui/ObjectGanttProps:gantt", + "ui/ObjectGanttProps:holidays", + "ui/ObjectGanttProps:label", + "ui/ObjectGanttProps:markers", + "ui/ObjectGanttProps:mobileReadOnly", + "ui/ObjectGanttProps:navigation", + "ui/ObjectGanttProps:objectName", + "ui/ObjectGanttProps:persistLayout", + "ui/ObjectGanttProps:readOnly", + "ui/ObjectGanttProps:showBaselines", + "ui/ObjectGanttProps:skipWeekends", + "ui/ObjectGanttProps:sort", + "ui/ObjectGanttProps:staticData", + "ui/ObjectGanttProps:viewName", "ui/ObjectGridProps:aggregations", "ui/ObjectGridProps:batchActions", "ui/ObjectGridProps:bulkActionDefs", @@ -835,6 +852,15 @@ "ui/ObjectListView:userActions", "ui/ObjectListView:userFilters", "ui/ObjectListView:virtualScroll [RETIRED]", + "ui/ObjectMapProps:data", + "ui/ObjectMapProps:enableClustering", + "ui/ObjectMapProps:filter", + "ui/ObjectMapProps:map", + "ui/ObjectMapProps:mapStyle", + "ui/ObjectMapProps:navigation", + "ui/ObjectMapProps:objectName", + "ui/ObjectMapProps:sort", + "ui/ObjectMapProps:staticData", "ui/ObjectMasterDetailFormProps:cancelText", "ui/ObjectMasterDetailFormProps:details", "ui/ObjectMasterDetailFormProps:fields", @@ -884,6 +910,12 @@ "ui/ObjectNavItem:type", "ui/ObjectNavItem:viewName", "ui/ObjectNavItem:visible", + "ui/ObjectTreeProps:data", + "ui/ObjectTreeProps:filter", + "ui/ObjectTreeProps:navigation", + "ui/ObjectTreeProps:objectName", + "ui/ObjectTreeProps:staticData", + "ui/ObjectTreeProps:tree", "ui/ObjectUserFilters:element", "ui/ObjectUserFilters:fields", "ui/Page:_lock", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 46320c7d107..4d8bc012959 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -168,17 +168,23 @@ "ObjectCalendarPropsSchema": "ui/ObjectCalendarProps", "ObjectFormProps": "ui/ObjectFormProps", "ObjectFormPropsSchema": "ui/ObjectFormProps", + "ObjectGanttProps": "ui/ObjectGanttProps", + "ObjectGanttPropsSchema": "ui/ObjectGanttProps", "ObjectGridProps": "ui/ObjectGridProps", "ObjectGridPropsSchema": "ui/ObjectGridProps", "ObjectKanbanProps": "ui/ObjectKanbanProps", "ObjectKanbanPropsSchema": "ui/ObjectKanbanProps", "ObjectListViewSchema": "ui/ObjectListView", + "ObjectMapProps": "ui/ObjectMapProps", + "ObjectMapPropsSchema": "ui/ObjectMapProps", "ObjectMasterDetailFormProps": "ui/ObjectMasterDetailFormProps", "ObjectMasterDetailFormPropsSchema": "ui/ObjectMasterDetailFormProps", "ObjectMetricProps": "ui/ObjectMetricProps", "ObjectMetricPropsSchema": "ui/ObjectMetricProps", "ObjectNavItem": "ui/ObjectNavItem", "ObjectNavItemSchema": "ui/ObjectNavItem", + "ObjectTreeProps": "ui/ObjectTreeProps", + "ObjectTreePropsSchema": "ui/ObjectTreeProps", "ObjectUserFiltersSchema": "ui/ObjectUserFilters", "Page": "ui/Page", "PageAccordionProps": "ui/PageAccordionProps", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 2586f890099..fbc7b023e10 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -252,6 +252,9 @@ "ObjectCalendarPropsSchema": "src/ui/component.zod.ts#ObjectCalendarPropsSchema (const)", "ObjectFormProps": "src/ui/component.zod.ts#ObjectFormProps (type)", "ObjectFormPropsSchema": "src/ui/component.zod.ts#ObjectFormPropsSchema (const)", + "ObjectGanttProps": "src/ui/component.zod.ts#ObjectGanttProps (type)", + "ObjectGanttPropsParsed": "src/ui/component.zod.ts#ObjectGanttPropsParsed (type)", + "ObjectGanttPropsSchema": "src/ui/component.zod.ts#ObjectGanttPropsSchema (const)", "ObjectGridProps": "src/ui/component.zod.ts#ObjectGridProps (type)", "ObjectGridPropsParsed": "src/ui/component.zod.ts#ObjectGridPropsParsed (type)", "ObjectGridPropsSchema": "src/ui/component.zod.ts#ObjectGridPropsSchema (const)", @@ -259,6 +262,9 @@ "ObjectKanbanPropsParsed": "src/ui/component.zod.ts#ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema": "src/ui/component.zod.ts#ObjectKanbanPropsSchema (const)", "ObjectListViewSchema": "src/ui/view.zod.ts#ObjectListViewSchema (const)", + "ObjectMapProps": "src/ui/component.zod.ts#ObjectMapProps (type)", + "ObjectMapPropsParsed": "src/ui/component.zod.ts#ObjectMapPropsParsed (type)", + "ObjectMapPropsSchema": "src/ui/component.zod.ts#ObjectMapPropsSchema (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)", @@ -267,6 +273,9 @@ "ObjectNavItem": "src/ui/app.zod.ts#ObjectNavItem (type)", "ObjectNavItemParsed": "src/ui/app.zod.ts#ObjectNavItemParsed (type)", "ObjectNavItemSchema": "src/ui/app.zod.ts#ObjectNavItemSchema (const)", + "ObjectTreeProps": "src/ui/component.zod.ts#ObjectTreeProps (type)", + "ObjectTreePropsParsed": "src/ui/component.zod.ts#ObjectTreePropsParsed (type)", + "ObjectTreePropsSchema": "src/ui/component.zod.ts#ObjectTreePropsSchema (const)", "ObjectUserFiltersSchema": "src/ui/view.zod.ts#ObjectUserFiltersSchema (const)", "PAGE_TYPE_ROADMAP": "src/ui/page.zod.ts#PAGE_TYPE_ROADMAP (const)", "Page": "src/ui/page.zod.ts#Page (type)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 89529d01440..1d2cfcd4db7 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -92,12 +92,15 @@ "ui/NotificationType", "ui/ObjectCalendarProps", "ui/ObjectFormProps", + "ui/ObjectGanttProps", "ui/ObjectGridProps", "ui/ObjectKanbanProps", "ui/ObjectListView", + "ui/ObjectMapProps", "ui/ObjectMasterDetailFormProps", "ui/ObjectMetricProps", "ui/ObjectNavItem", + "ui/ObjectTreeProps", "ui/ObjectUserFilters", "ui/Page", "ui/PageAccordionProps", diff --git a/packages/spec/src/ui/component-type-vocabulary.test.ts b/packages/spec/src/ui/component-type-vocabulary.test.ts index e6222cbefe8..bf0956c8f9d 100644 --- a/packages/spec/src/ui/component-type-vocabulary.test.ts +++ b/packages/spec/src/ui/component-type-vocabulary.test.ts @@ -75,6 +75,28 @@ describe('KNOWN_COMPONENT_TYPES covers every declared face', () => { } }); + /** + * #18305 — the three `object-*` blocks that entered the map when the + * objectui#8348 ruling 「8348 以协议为准」 was executed. The known set is + * DERIVED from `Object.keys(ComponentPropsMap)`, so this pin is not a second + * list: it is the card's stated acceptance ("`Object.keys(ComponentPropsMap)` + * pins list the three new rows") asked of the vocabulary, which is the reader + * the row set feeds. `object-chart` stays unknown beside them — its absence is + * deliberate and has its own note in `component.zod.ts`, so a sweep that took + * it in by accident is caught here rather than in a rendered page. + */ + it('the three #18305 blocks are known through their rows; object-chart still is not', () => { + for (const type of ['object-map', 'object-gantt', 'object-tree']) { + expect(Object.keys(ComponentPropsMap), type).toContain(type); + expect(isKnownComponentType(type), type).toBe(true); + // Every `object-*` type reaches `PageComponentSchema` through the open + // string arm, which the rows do not narrow — the parse is unchanged. + expect(KNOWN_COMPONENT_TYPE_CANDIDATES, type).toContain(type); + } + expect(Object.keys(ComponentPropsMap)).not.toContain('object-chart'); + expect(isKnownComponentType('object-chart')).toBe(false); + }); + /** * #12950's own readiness verdict, pinned: `global:search` and * `global:notifications` STAY declared — the 2026-08-26 ruling retires a diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 06262d8e56e..b1ee3b947e1 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -24,6 +24,8 @@ import { ObjectKanbanPropsSchema, } from './component.zod'; import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; +import { GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema } from './view.zod'; +import { strictObjectDeclarations } from '../shared/strict-object'; describe('PageHeaderProps', () => { it('should accept minimal header', () => { @@ -2073,12 +2075,19 @@ describe("element:record_picker `filter` — one filter orthography platform-wid }); // --------------------------------------------------------------------------- -// The four `object-*` `filter` doors — the ViewFilterRule ARRAY orthography +// The seven `object-*` `filter` doors — the ViewFilterRule ARRAY orthography // (ui#6206-B reaching the object-* family: #15449, folded into #15442, -// decision batch #55, option A: family-wide, one ADR-0087 D3 entry) +// decision batch #55, option A: family-wide, one ADR-0087 D3 entry). The last +// three joined at #18305, when `object-map` / `object-gantt` / `object-tree` +// got their rows: a NEW `filter` door on this family declares the ruled +// orthography from birth — the ruling is family-wide, so there is no +// "measured before the ruling" arm left for a door that did not exist then. // --------------------------------------------------------------------------- -describe('the four `object-*` `filter` doors — one filter orthography platform-wide (ui#6206-B, #15449)', () => { - const OBJECT_DOORS = ['object-grid', 'object-metric', 'object-kanban', 'object-calendar'] as const; +describe('the seven `object-*` `filter` doors — one filter orthography platform-wide (ui#6206-B, #15449, #18305)', () => { + const OBJECT_DOORS = [ + 'object-grid', 'object-metric', 'object-kanban', 'object-calendar', + 'object-map', 'object-gantt', 'object-tree', + ] as const; const RULES = [{ field: 'status', operator: 'not_equals', value: 'done' }]; const RECORD_FORM = { status: { $ne: 'done' } }; /** The showcase's `object-grid` used to author THIS — an ObjectQL AST tuple array. */ @@ -2095,7 +2104,10 @@ describe('the four `object-*` `filter` doors — one filter orthography platform // hand it verbatim to `$filter`, where `convertQueryParams` lowers it; the // metric's aggregate path lowers it through `translateFilterArray` and // `parseFilterAST` before `POST /analytics/query` (objectui#7754 — the - // door the family was sequenced behind, #15828 / #16626). + // door the family was sequenced behind, #15828 / #16626). Re-measured at + // the same pin for the three #18305 doors: `ObjectMap.tsx:742`, + // `ObjectGantt.tsx:738` and `ObjectTree.tsx:474` each hand `schema.filter` + // verbatim to `$filter`, the kanban/calendar shape. const r = door(type).safeParse({ objectName: 'showcase_task', filter: RULES }); expect(r.success).toBe(true); expect(r.data!.filter).toEqual(RULES); @@ -2155,8 +2167,16 @@ describe('the four `object-*` `filter` doors — one filter orthography platform }); }); -describe('`object-grid` / `object-calendar` `sort` — one sort orthography, the array (objectui#8221, decision batch #77, option B)', () => { - const SORT_DOORS = ['object-grid', 'object-calendar'] as const; +describe('the four `object-*` `sort` doors — one sort orthography, the array (objectui#8221, decision batch #77, option B; #18305)', () => { + // `object-map` and `object-gantt` joined at #18305: both hand `schema.sort` + // to the SAME shared sink the grid and the calendar do + // (`convertSortToQueryParams`, `core/src/utils/sort-query.ts`) — + // `ObjectMap.tsx:743`, `ObjectGantt.tsx:739` at the pin `53ded82b`. + // `object-tree` is deliberately NOT here: its fetch (`ObjectTree.tsx:473-484`) + // carries `$filter`, `$top` and `$expand` and no `$orderby` at all, so its row + // declares no `sort` — a door with no read site is what this family refuses to + // publish. + const SORT_DOORS = ['object-grid', 'object-calendar', 'object-map', 'object-gantt'] as const; const ARRAY_FORM = [{ field: 'created_at', order: 'desc' }]; /** * The legacy OData-ish clause `convertSortToQueryParams` honours at the @@ -2881,10 +2901,15 @@ describe('#7751 — object-* block props schemas', () => { return r.error.issues.map((i: { message: string }) => i.message).join('\n'); }; - it('the six ruled blocks are registered; object-chart deliberately is NOT', () => { + it('the nine ruled blocks are registered; object-chart deliberately is NOT', () => { + // Six at #7751, three more at #18305 (`object-map` / `object-gantt` / + // `object-tree`) — the blocks that section enumerated past rather than + // ruled out. `Object.keys(ComponentPropsMap)` is what every downstream + // reader dispatches on, so the row set is pinned by name here. for (const type of [ 'object-grid', 'object-metric', 'object-kanban', 'object-calendar', 'object-form', 'object-master-detail-form', + 'object-map', 'object-gantt', 'object-tree', ]) { expect(ComponentPropsMap[type as keyof typeof ComponentPropsMap], type).toBeDefined(); } @@ -3091,7 +3116,10 @@ describe('#7751 — object-* block props schemas', () => { }); 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) { + for (const type of [ + 'object-grid', 'object-metric', 'object-kanban', 'object-calendar', + 'object-map', 'object-gantt', 'object-tree', + ] as const) { const message = refuse(ComponentPropsMap[type], { filters: [] }); expect(message, type).toContain('Did you mean `filters` → `filter`?'); } @@ -3113,6 +3141,7 @@ describe('#7751 — object-* block props schemas', () => { for (const type of [ 'object-grid', 'object-metric', 'object-kanban', 'object-calendar', 'object-form', 'object-master-detail-form', + 'object-map', 'object-gantt', 'object-tree', ] as const) { expect(ComponentPropsMap[type].safeParse({}).success, type).toBe(true); } @@ -3358,3 +3387,177 @@ describe('ObjectMetricPropsSchema icon liveness (#10053)', () => { expect(shape.icon?.description).toContain('MetricWidget'); }); }); + +// --------------------------------------------------------------------------- +// #18305 — `object-map` / `object-gantt` / `object-tree` get their +// `ComponentPropsMap` rows, executing the objectui#8348 ruling +// 「8348 以协议为准」 (batch #83, 2026-09-08) and batch #136 item 3 (Q1-C). +// +// The acceptance the card names, pinned: each row's KEY SET is the one the +// renderer's read points support at the `.objectui-sha` pin `53ded82b`, and +// `Object.keys(ComponentPropsMap)` lists the three. The key sets are asserted +// WHOLE rather than by spot-check — a row derived from read points is a claim +// about a complete set, and only an equality can hold a later addition to +// having been measured too. +// --------------------------------------------------------------------------- +describe('the three #18305 object blocks — key sets derived from the renderers read points', () => { + type Shape = { shape: Record; safeParse(v: unknown): any }; + const door = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Shape; + const keysOf = (type: string) => Object.keys(door(type).shape).sort(); + const refuse = (type: string, value: unknown): string => { + const r = door(type).safeParse(value); + expect(r.success).toBe(false); + return r.error.issues.map((i: { message: string }) => i.message).join('\n'); + }; + + it('object-map declares exactly its measured read set', () => { + // ObjectMap.tsx @ 53ded82b: data (:169 -> resolveRecordSourceConfig :174), + // staticData / objectName (the shared ladder's rungs 2 and 3), filter + // (:742), sort (:743), map (:370), mapStyle (:365), navigation (:889), + // enableClustering (:905). + expect(keysOf('object-map')).toEqual([ + 'data', 'enableClustering', 'filter', 'map', 'mapStyle', 'navigation', 'objectName', 'sort', 'staticData', + ]); + }); + + it('object-gantt declares exactly its measured read set', () => { + // ObjectGantt.tsx @ 53ded82b: the ladder (:593), filter (:738), sort + // (:739), gantt (:499), navigation (:1487), label (:1849), skipWeekends + // (:1205), holidays (:1206), persistLayout (:1357), viewName (:1359), + // markers (:1826), criticalPath (:1829), showBaselines (:1832), readOnly + // (:1833), mobileReadOnly (:1834). + expect(keysOf('object-gantt')).toEqual([ + 'criticalPath', 'data', 'filter', 'gantt', 'holidays', 'label', 'markers', 'mobileReadOnly', + 'navigation', 'objectName', 'persistLayout', 'readOnly', 'showBaselines', 'skipWeekends', + 'sort', 'staticData', 'viewName', + ]); + }); + + it('object-tree declares exactly its measured read set — and `data` IS in it', () => { + // The card's open question, answered by measurement rather than by family + // symmetry: ObjectTree.tsx @ 53ded82b reaches `schema.data` through + // `resolveRecordSourceConfig(schema)` at :359 — rung 1 of the shared + // ladder, which returns the authored value VERBATIM as a `ViewData`. That + // ONE site is the whole support for the object arm, and it is sufficient. + // ⛔ :496 is NOT a second one: `(rest as any).data ?? (schema as any).data` + // is gated by `Array.isArray(passed)` on the next line, so it honours only + // the bare-ARRAY shorthand this row REFUSES (pinned below). objectui#9234 + // left the :359 read marked `undeclared` because neither published face + // carried the key; the protocol row follows the READ POINTS, which is what + // 「以协议为准」 resolving for this block means. + expect(keysOf('object-tree')).toEqual([ + 'data', 'filter', 'navigation', 'objectName', 'staticData', 'tree', + ]); + // …and NOT `sort`: this renderer's fetch carries $filter, $top and $expand + // and no $orderby, so a `sort` door here would publish a key with no read + // site. The negative is the other half of "derived from read points". + expect(keysOf('object-tree')).not.toContain('sort'); + }); + + it('`data` takes the ViewData object arm on all three — the arm the shared ladder returns verbatim', () => { + for (const type of ['object-map', 'object-gantt', 'object-tree'] as const) { + const bound = door(type).safeParse({ data: { provider: 'object', object: 'showcase_task' } }); + expect([type, bound.success]).toEqual([type, true]); + const inline = door(type).safeParse({ data: { provider: 'value', items: [{ id: 1 }] } }); + expect([type, inline.success]).toEqual([type, true]); + // The bare-array shorthand two of these renderers normalize is off + // contract — `ViewData` is a discriminated union over OBJECT variants — + // so it is refused here exactly as it is on `object-grid`. + const bare = door(type).safeParse({ data: [{ id: 1 }] }); + expect([type, bare.success]).toEqual([type, false]); + // Inline rows have their own declared door, and it is an array. + const staticRows = door(type).safeParse({ staticData: [{ id: 1 }] }); + expect([type, staticRows.success]).toEqual([type, true]); + } + }); + + it('the flat config spellings are refused with the wrong-layer prescription, not a rename', () => { + // The ObjectView / ListView flatten product: read by all three renderers, + // ruled an internal transport form rather than a second authoring surface + // (objectui#5018 for the map, inherited by objectui#6469 for the gantt; + // one composition key per concept for the tree). + const mapMsg = refuse('object-map', { objectName: 'task', latitudeField: 'lat' }); + expect(mapMsg).toContain('`map`'); + expect(mapMsg).toContain('latitudeField'); + const ganttMsg = refuse('object-gantt', { objectName: 'task', startDateField: 'starts_at' }); + expect(ganttMsg).toContain('`gantt`'); + expect(ganttMsg).toContain('startDateField'); + const treeMsg = refuse('object-tree', { objectName: 'task', parentField: 'parent_id' }); + expect(treeMsg).toContain('`tree`'); + expect(treeMsg).toContain('parentField'); + }); + + it('each flat-key set is HELD EQUAL to the config block it points at — it cannot drift silently', () => { + // The lists are spelled out at the declaration (forcing a `lazySchema` + // proxy at module load would build `view.zod` mid-initialisation), so the + // derivation is asserted here instead. A key added to a config block on + // either face lands in this assertion, not in a silent gap between the + // block and the prescription that sends authors to it. + const setFor = (type: string, name: string): readonly string[] => { + // Force the row first: declarations register when their `lazySchema` body + // runs, so a registry read before that returns a set this row is not in. + void door(type).shape; + const decl = strictObjectDeclarations().find((d) => d.options.surface === `this \`${type}\``); + expect(decl, type).toBeDefined(); + const set = (decl!.options.guidanceSets ?? []).find((g) => g.name === name); + expect(set, name).toBeDefined(); + expect(Array.isArray(set!.keys), name).toBe(true); + return [...(set!.keys as readonly string[])].sort(); + }; + // map: `ListMapConfigSchema`'s own shape, key for key. + expect(setFor('object-map', 'OBJECT_MAP_FLAT_CONFIG_KEYS')) + .toEqual(Object.keys(ListMapConfigSchema.shape).sort()); + // gantt: `GanttConfigSchema`'s shape PLUS the legacy singular alias the + // renderer's flat branch still reads beside `dependenciesField`. + expect(setFor('object-gantt', 'OBJECT_GANTT_FLAT_CONFIG_KEYS')) + .toEqual([...Object.keys(GanttConfigSchema.shape), 'dependencyField'].sort()); + // tree: `TreeConfigSchema`'s shape PLUS `titleField`, which `getTreeConfig` + // reads only as `labelField`'s last fallback (ObjectTree.tsx:117). + expect(setFor('object-tree', 'OBJECT_TREE_FLAT_CONFIG_KEYS')) + .toEqual([...Object.keys(TreeConfigSchema.shape), 'titleField'].sort()); + }); + + it('the config blocks are the spec own schemas where the renderer names one, `z.unknown()` where it does not', () => { + // gantt: `ObjectGantt.tsx:501` validates the authored block against + // `GanttConfigSchema` imported from `@objectstack/spec/ui`, so the read + // point names the schema and the door takes it — a misspelling inside the + // block is refused here exactly as the renderer's own safeParse warns. + expect(door('object-gantt').safeParse({ + gantt: { startDateField: 's', endDateField: 'e', titleField: 't' }, + }).success).toBe(true); + expect(door('object-gantt').safeParse({ + gantt: { startDateField: 's', endDateField: 'e', titleField: 't', colourField: 'status' }, + }).success).toBe(false); + // tree: `TreeConfigSchema`, closed at #15469 on this very measurement. + expect(door('object-tree').safeParse({ tree: { parentField: 'parent_id' } }).success).toBe(true); + expect(door('object-tree').safeParse({ tree: { labelFeild: 'name' } }).success).toBe(false); + // map: `z.unknown()` — the spec's `ListMapConfigSchema` is strict and + // declares no `style`, the key `getMapConfig` reads at `ObjectMap.tsx:365` + // (`schema.map?.style`), so pointing this door at it would refuse a value + // the renderer honours. The gap is on the list-view face; this pin records + // WHY the value stays open here so the later ratchet has its reason. + expect(ListMapConfigSchema.safeParse({ style: 'https://tiles.example/style.json' }).success).toBe(false); + expect(door('object-map').safeParse({ map: { latitudeField: 'lat', style: 'https://tiles.example/style.json' } }).success).toBe(true); + }); + + it('every one of the three still refuses an undeclared key BY NAME — the control', () => { + for (const type of ['object-map', 'object-gantt', 'object-tree'] as const) { + const r = door(type).safeParse({ objectName: 'task', bogusProp: 1 }); + expect([type, r.success]).toEqual([type, false]); + const unrecognized = r.error.issues.filter((i: { code: string }) => i.code === 'unrecognized_keys'); + expect(unrecognized.flatMap((i: { keys?: string[] }) => i.keys ?? [])).toContain('bogusProp'); + } + }); + + it('each row carries the objectBlockHistory line — the silence it ends is named in the refusal', () => { + for (const type of ['object-map', 'object-gantt', 'object-tree'] as const) { + const message = refuse(type, { bogusProp: 1 }); + expect(message, type).toContain('had no entry there at all'); + expect(message, type).toContain(type); + } + }); + + it('object-chart is STILL deliberately absent — the three rows did not sweep it in', () => { + expect((ComponentPropsMap as Record)['object-chart']).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 65eb6d491b1..c2442315807 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { ViewFilterRuleSchema, ViewDataSchema } from './view.zod'; +import { ViewFilterRuleSchema, ViewDataSchema, GanttConfigSchema, TreeConfigSchema } from './view.zod'; import { InlineActionSchema, ActionLocationSchema } from './action.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; @@ -3148,6 +3148,430 @@ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectMasterDetailFormProps = z.input; +/** + * The flat per-field spellings `ObjectMap` reads as the ObjectView / ListView + * flatten product (`getMapConfig` branch 2, `ObjectMap.tsx:382-396`) and that + * both view layers EMIT (`plugin-view/src/ObjectView.tsx` and + * `plugin-list/src/ListView.tsx`, `case 'map'`, which spread `options.map`'s + * CONTENTS at the top level and carry no `map` key at all). Read, but NOT + * authorable: the maintainer ruled that shape an internal transport form rather + * than a second authoring surface (objectui#5018, 2026-08-17), and the renderer + * says so itself — when a `map` block is present it wins outright and every flat + * key beside it is named as IGNORED in a dev warning + * (`warnOnShadowedFlatMapKeys`). One composition key per concept (Prime + * Directive #12), the same channel {@link ObjectCalendarPropsSchema} uses above. + * + * The member list is objectui's own `FLAT_MAP_CONFIG_KEYS` — `ObjectMapConfig`'s + * keys minus `style` — and `component.test.ts` holds it equal to the spec's own + * {@link ListMapConfigSchema} shape, so a key added to the config block on + * either face cannot leave this prescription behind. + */ +const OBJECT_MAP_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ + ...COMPONENT_LEVEL_GUIDANCE, + { + name: 'OBJECT_MAP_FLAT_CONFIG_KEYS', + keys: ['latitudeField', 'longitudeField', 'locationField', 'titleField', 'descriptionField', 'zoom', 'center'], + examples: ['latitudeField', 'titleField'], + prescription: + 'Write this as a key of the `map` config object instead — `map: { latitudeField, longitudeField, ' + + 'locationField, titleField, descriptionField, zoom, center }`. The flat spelling is the internal ' + + 'form `ObjectView`/`ListView` produce when they flatten `options.map`, not a second authoring ' + + 'spelling: whenever a `map` block is present the renderer takes it whole and names every flat key ' + + 'beside it as ignored. The map STYLE is the top-level `mapStyle` (or `map.style`) — never `style`, ' + + "which is the component node's inline CSS record.", + }, +]; + +/** + * `object-map` (objectui `plugin-map/src/ObjectMap.tsx` plus the registry shell + * `plugin-map/src/index.tsx`, measured at the `.objectui-sha` pin `53ded82b`). + * Read points per key: `data` (`:169`, the array-shorthand head, then + * `resolveRecordSourceConfig` at `:174` — rung 1 of the ruled record-source + * ladder, `core/src/utils/record-source.ts:151`), `staticData` (rung 2, + * `record-source.ts:155`), `objectName` (rung 3, `record-source.ts:162`; also + * `:793` and `:890` here), `filter` (`:742`, handed verbatim to `$filter`), + * `sort` (`:743`, through the shared `convertSortToQueryParams` sink to + * `$orderby`), `map` (`:370` — `getMapConfig` branch 1, the author face and the + * registration's declared `{ name: 'map', type: 'object' }` input), `mapStyle` + * (`:365`, `schema.mapStyle || schema.map?.style`), `navigation` (`:889`) and + * `enableClustering` (`:905`). + * + * Measured and deliberately NOT declared: + * + * - the flat `map`-config spellings — the ObjectView/ListView flatten product, + * ruled an internal transport form (objectui#5018). They get + * {@link OBJECT_MAP_FLAT_CONFIG_GUIDANCE}'s wrong-layer prescription. + * - `style`. `ObjectMap.tsx:283` reads it only to say it is NOT consumed as a + * map style (objectui#5017): it is `BaseSchema.style`, the node's inline CSS + * record, and `COMPONENT_NODE_KEYS` above already sends it back to the node. + * - `clusterRadius`, `data` as an ARRAY, `onMarkerClick` / `onRowClick` / + * `onEdit` / `onDelete`, `className` and `dataSource` — React props of + * `ObjectMapProps`, host-injected, never authored metadata. + * + * VALUE posture for `map`: `z.unknown()`, the section's "the value contract + * still lives in objectui" arm, and measured rather than assumed. The spec's own + * {@link ListMapConfigSchema} is strict and declares the same seven field keys, + * but NOT the `style` key `getMapConfig` reads at `:365` (`schema.map?.style`), + * so pointing this door at it would refuse a value the renderer honours today. + * Tightening it is the later value ratchet this section describes, once that + * one-key gap is closed on the list-view face it belongs to. + */ +export const ObjectMapPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-map`', + history: objectBlockHistory('object-map'), + guidanceSets: OBJECT_MAP_FLAT_CONFIG_GUIDANCE, + aliases: FILTERS_TO_FILTER, +}, { + objectName: z.string().optional() + .describe('Object this map binds to — the THIRD record source `getDataConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead'), + /** + * Data source binding — `ViewDataSchema`, the same object arm `object-grid` + * declares above. Derived from the read point, not from objectui's mirror: + * rung 1 of `resolveRecordSourceConfig` returns `schema.data` VERBATIM as the + * record-source config, and `ViewData` is what every consumer of that return + * value is typed against. (`ObjectMapSchema.data` on `@object-ui/types` + * happens to spell it the same way — read after the derivation, as a check on + * it, never as the source of it.) + * + * The bare-array shorthand `ObjectMap.tsx:169-172` normalizes is NOT declared: + * `ViewData` is a discriminated union over OBJECT variants, so an array under + * `data` cannot be published, and `staticData` below is this block's declared + * door for inline rows — the renderer's own comment says so. + */ + data: ViewDataSchema.optional() + .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | api | value | schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), + staticData: z.array(z.unknown()).optional() + .describe("Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config"), + /** + * Base query filter — the `ViewFilterRule` ARRAY form, the one filter + * orthography every `filter` door in this map shares (ui#6206-B reaching the + * `object-*` family: #15449, decision batch #55, option A). Measured at the + * objectui pin `53ded82b`: `ObjectMap.tsx:742` hands `schema.filter` verbatim + * to `$filter`, where the adapter lowers a rule array exactly as it does for + * the kanban and the calendar. The record form is refused at `filter`. + */ + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-map`', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() + .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), + /** + * Marker order — the `SortItem` ARRAY form, the one sort orthography every + * DECLARED `sort` door on this platform carries (objectui#8221, decision batch + * #77, option B). Measured at the objectui pin `53ded82b`: `ObjectMap.tsx:743` + * hands `schema.sort` to the shared `convertSortToQueryParams` sink as the + * fetch's `$orderby` — the same sink `object-grid` and `object-calendar` + * declare against, so the legacy string clause is refused here for the same + * ruling. `plugin-map/src/index.tsx` declares no `sort` input at all, so + * nothing on the registry side moves. + */ + sort: z.array(SortItemSchema).optional() + .describe('Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array`'), + map: z.unknown().optional() + .describe('Map field config, the author face: { latitudeField, longitudeField, locationField?, titleField?, descriptionField?, zoom?, center?, style? }. Taken WHOLE when present — the flat top-level spelling beside it is ignored'), + mapStyle: z.string().optional() + .describe('MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record'), + navigation: z.unknown().optional() + .describe('Marker-click navigation config ({ mode: page | drawer | modal | split | popover | none })'), + enableClustering: z.boolean().optional() + .describe('Group nearby markers into clusters. Absent, the renderer clusters only above 100 markers'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectMapProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state — `filter` carries + * `z.array(ViewFilterRuleSchema)` (`operator` normalizes on parse) and `data` + * carries `ViewDataSchema`, so this block leaves the type-alias convention pin's + * default-free family the way `object-grid` did. + */ +export type ObjectMapPropsParsed = z.infer; + +/** + * The flat `GanttConfig` spellings `getGanttConfig`'s branch 2 reads + * (`ObjectGantt.tsx:513-543`) and that `ObjectView` / `ListView` EMIT when they + * flatten `options.gantt` — objectui's own `FLAT_GANTT_CONFIG_KEYS`. Read, but + * NOT authorable, for the reason the map's twin set above records: objectui#6469 + * inherited the objectui#5018 ruling for this block, so the `gantt` block is the + * authoring shape, it is taken WHOLE when present, and every flat key beside it + * is named as ignored in a dev warning. + * + * The flat branch is the HOT path for this block — a hand-authored `gantt` block + * reaching the renderer through either view layer has already been flattened — + * which is exactly why the prescription matters here: an author who learned the + * flat spelling from a view config (or from `@object-ui/types`' + * `ObjectGanttSchema`, which declares the whole flat face) writes it on an SDUI + * node next, where nothing flattens anything. + * + * Membership is `GanttConfigSchema`'s own shape plus the legacy singular + * `dependencyField` alias the flat branch still reads beside `dependenciesField` + * — the same two halves objectui derives its list from. Spelled out rather than + * read off `GanttConfigSchema.shape` here because that schema is a + * {@link lazySchema} proxy and forcing it at module load would build `view.zod` + * mid-initialisation (the import-cycle footgun `ruleArrayFilterError` defers + * around); `component.test.ts` holds the list equal to the shape instead. + */ +const OBJECT_GANTT_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ + ...COMPONENT_LEVEL_GUIDANCE, + { + name: 'OBJECT_GANTT_FLAT_CONFIG_KEYS', + keys: [ + 'assigneeField', 'autoZoomToFilter', 'baselineEndField', 'baselineStartField', 'borderColorField', + 'capacity', 'colorField', 'defaultCollapsedDepth', 'dependenciesField', 'dependencyField', + 'dependencyTypes', 'effortField', 'endDateField', 'exportFileName', 'groupByField', 'interactions', + 'lockField', 'objectField', 'parentField', 'progressField', 'quickFilters', 'resourceView', + 'startDateField', 'summaryExtent', 'timeSegments', 'timeZone', 'titleField', 'tooltipFields', + 'typeField', 'viewMode', + ], + examples: ['startDateField', 'endDateField', 'viewMode'], + prescription: + 'Write this as a key of the `gantt` config object instead — `gantt: { startDateField, endDateField, ' + + 'titleField, ... }`. The flat top-level spelling is the internal form `ObjectView`/`ListView` ' + + 'produce when they flatten `options.gantt`, not a second authoring spelling: whenever a `gantt` ' + + 'block is present the renderer takes it WHOLE and names every flat key beside it as ignored. The ' + + 'legacy singular `dependencyField` is `dependenciesField` inside that block.', + }, +]; + +/** + * `object-gantt` (objectui `plugin-gantt/src/ObjectGantt.tsx` plus the registry + * shell `plugin-gantt/src/index.tsx`, measured at the `.objectui-sha` pin + * `53ded82b`). Read points per key: `data` (`resolveRecordSourceConfig` at + * `:593` — rung 1, `core/src/utils/record-source.ts:151`), `staticData` (rung 2, + * `record-source.ts:155`), `objectName` (rung 3; also `:1359` and `:1491` here), + * `filter` (`:738`, verbatim to `$filter`), `sort` (`:739`, through + * `convertSortToQueryParams` to `$orderby`), `gantt` (`:499-501` — + * `getGanttConfig` branch 1, the author face and the registration's declared + * `{ name: 'gantt', type: 'object' }` input, validated there against this + * repo's own {@link GanttConfigSchema}), `navigation` (`:1487`), `label` + * (`:1871`, resolved through `resolveI18nLabel` for the export file name — + * `:1849` is the comment ABOVE that chain, not a read), + * `skipWeekends` (`:1205`), `holidays` (`:1206`), `persistLayout` (`:1357`), + * `viewName` (`:1359`), `markers` (`:1826`), `criticalPath` (`:1829`), + * `showBaselines` (`:1832`), `readOnly` (`:1833` and `:1916`) and + * `mobileReadOnly` (`:1834`). + * + * Measured and deliberately NOT declared: the flat `GanttConfig` spellings (the + * flatten product — {@link OBJECT_GANTT_FLAT_CONFIG_GUIDANCE}); `title`, which + * this renderer never reads (the export-name chain is `gantt.exportFileName` → + * `label` → the OBJECT's label → `objectName`, `:1869-1874`); a row cap — the + * reload's `$top` is the platform ceiling `NON_GRID_ROW_CEILING_TOP` and the + * renderer's own comment marks it "⛔ Not authorable"; and the `onTaskClick` / + * `onRowClick` / `onBeforeTaskUpdate` callbacks, which are host props. + * + * VALUE posture: `gantt` is the one config block in this family whose value + * contract is already the SPEC's — `ObjectGantt.tsx:500` validates it against + * `GanttConfigSchema` imported from `@objectstack/spec/ui` — so the read point + * names the schema and this door takes it rather than `z.unknown()`. The + * scalars below are read as their coercions say: `!!schema.readOnly`, + * `schema.showBaselines !== false`, `schema.persistLayout === false`, + * `new Set(schema.holidays)`. `markers` stays `z.array(z.unknown())` — its + * element contract is `GanttView`'s `GanttMarker`, still objectui's. + */ +export const ObjectGanttPropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-gantt`', + history: objectBlockHistory('object-gantt'), + guidanceSets: OBJECT_GANTT_FLAT_CONFIG_GUIDANCE, + aliases: FILTERS_TO_FILTER, +}, { + objectName: z.string().optional() + .describe('Object this gantt binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead'), + /** + * Data source binding — `ViewDataSchema`, spelled exactly as `object-map`'s + * and `object-grid`'s. Derived from the read point: rung 1 of + * `resolveRecordSourceConfig` returns `schema.data` VERBATIM as the + * record-source config the fetch resolves through `resolveDataSource`. + */ + data: ViewDataSchema.optional() + .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | api | value | schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), + staticData: z.array(z.unknown()).optional() + .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), + /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-gantt`', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() + .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), + /** Task order — the platform's one `SortItem` array orthography (objectui#8221 option B). */ + sort: z.array(SortItemSchema).optional() + .describe('Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array`'), + gantt: GanttConfigSchema.optional() + .describe('Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored'), + navigation: z.unknown().optional() + .describe('Task-click navigation config ({ mode: page | drawer | modal | split | popover | none }); renderer default `drawer`'), + label: I18nLabelSchema.optional() + .describe('Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object\'s own label'), + skipWeekends: z.boolean().optional() + .describe('Measure duration and auto-schedule math in WORKING days, skipping Saturdays and Sundays'), + holidays: z.array(z.string()).optional() + .describe("Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math"), + persistLayout: z.boolean().optional() + .describe('Opt OUT of layout and filter-chip persistence — only an explicit `false` disables it; the storage key is `objectName:viewName`'), + viewName: z.string().optional() + .describe("Layout-persistence scope, the second half of the `objectName:viewName` storage key (renderer default `'default'`)"), + markers: z.array(z.unknown()).optional() + .describe('Extra vertical reference lines drawn like the Today marker ({ date, label?, color? })'), + criticalPath: z.boolean().optional() + .describe('Seed the critical-path highlight ON; the toolbar toggle stays available either way'), + showBaselines: z.boolean().optional() + .describe('Render the planned-vs-actual baseline bars — ON unless an explicit `false` disables it'), + readOnly: z.boolean().optional() + .describe('Disable every write path on this gantt and lock the record drawer'), + mobileReadOnly: z.boolean().optional() + .describe('Auto read-only on narrow viewports — ON unless an explicit `false` disables it'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectGanttProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state — `filter` carries + * `z.array(ViewFilterRuleSchema)` (`operator` normalizes on parse) and `data` + * carries `ViewDataSchema`, so this block leaves the type-alias convention pin's + * default-free family the way `object-grid` did. + */ +export type ObjectGanttPropsParsed = z.infer; + +/** + * The flat `TreeConfig` spellings `getTreeConfig` reads ahead of the `tree` + * block (`ObjectTree.tsx:108-119`) and that `ObjectView` / `ListView` EMIT when + * they flatten `options.tree` (`ListView.tsx:2597-2606`, `case 'tree'`: the + * product carries these keys and NO `tree` key). Read, but NOT authorable — + * one composition key per concept (Prime Directive #12), the ruling the map and + * the gantt carry from objectui#5018 / #6469 and the channel `object-calendar` + * uses above. The registration agrees: `plugin-tree/src/index.tsx` declares + * `{ name: 'tree', type: 'object' }` and no flat input. + * + * `titleField` is in the set although no `tree` block key is spelled that way: + * `getTreeConfig` reads it as the last fallback for `labelField` + * (`:117`), and `ListView`'s flatten resolves `treeCfg.titleField` into + * `labelField` before emitting, so the author's intent is always the block's + * `labelField`. + */ +const OBJECT_TREE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ + ...COMPONENT_LEVEL_GUIDANCE, + { + name: 'OBJECT_TREE_FLAT_CONFIG_KEYS', + keys: ['parentField', 'labelField', 'titleField', 'fields', 'defaultExpandedDepth'], + examples: ['parentField', 'labelField'], + prescription: + 'Write this as a key of the `tree` config object instead — `tree: { parentField, labelField, fields, ' + + 'defaultExpandedDepth }`. The flat top-level spelling is the internal form `ObjectView`/`ListView` ' + + 'produce when they flatten `options.tree`, not a second authoring spelling. A `titleField` is the ' + + "block's `labelField`: it is only ever read as that key's last fallback.", + }, +]; + +/** + * `object-tree` (objectui `plugin-tree/src/ObjectTree.tsx` plus the registry + * shell `plugin-tree/src/index.tsx`, measured at the `.objectui-sha` pin + * `53ded82b`). Read points per key: `data` (`resolveRecordSourceConfig` at + * `:359` — rung 1, `core/src/utils/record-source.ts:151`, which returns the + * authored value VERBATIM as a `ViewData`; that ONE site is the whole support + * for the arm this row declares), `staticData` (rung 2, + * `record-source.ts:155`), `objectName` (rung 3; also `:534`, `:570` and + * `:605` here), `filter` (`:474`, verbatim to `$filter`), `tree` (`:108`, the + * nested config block `getTreeConfig` reads and the registration's declared + * `{ name: 'tree', type: 'object' }` input) and `navigation` (`:591`). + * + * ⚠️ `data` IS declared here, and that is the measurement, not a family + * symmetry. objectui#9234 left this block's rung-1 read marked `undeclared` + * because neither published face carried the key: `ObjectTreeSchema` on + * `@object-ui/types` declares no `data`, no `staticData`, no `filter` and no + * `navigation` at all, and requires `objectName`. The renderer reads all four + * — so the protocol row follows the READ POINTS, which is what 「以协议为准」 + * resolving for this block means, and the mirror is the face that has to + * follow. + * + * ⛔ `:496` is NOT a second site for the object arm, and citing it as one would + * be citing a read of the opposite SHAPE: `(rest as any).data ?? (schema as + * any).data` is gated by `Array.isArray(passed)` on the very next line, so it + * honours only the bare-ARRAY shorthand this row REFUSES — the same shorthand + * `object-map` measures and declines one section up. One ladder site is + * sufficient, and `:359` is it. + * + * Measured and deliberately NOT declared: the flat `TreeConfig` spellings + * ({@link OBJECT_TREE_FLAT_CONFIG_GUIDANCE}); the bare-array `data` shorthand + * `:496-497` accepts, which `ViewData` cannot publish (a discriminated union + * over OBJECT variants) and for which `staticData` is this block's declared + * door; `sort` — this renderer's fetch + * (`:473-484`) carries `$filter`, `$top` and `$expand` and NO `$orderby`, and + * nothing else reads an order, so declaring one would publish a key with no read + * site; a row cap, for the same reason `object-gantt` declares none (the `$top` + * is the platform ceiling, marked "⛔ Not authorable" at `:482`); and + * `filter.tree`, the legacy stash `:108` still reads, which is a shape to stop + * writing rather than a key to declare. + * + * This is also the one block of the three whose type is absent from the tracked + * `sdui.manifest.json`, so `check:react-declaration-parity` reports it as + * missing from the registry rather than comparing it — the derivation above is + * from the renderer's sources at the pin either way, which is what #7751's + * method asks for. + * + * VALUE posture for `tree`: {@link TreeConfigSchema}, this repo's own block — + * #15469 closed it against unknown keys on exactly this measurement + * (`getTreeConfig` reads precisely those four keys from the block; the + * undeclared read set was EMPTY), re-measured here at `53ded82b` and unchanged. + */ +export const ObjectTreePropsSchema = lazySchema(() => strictObject({ + surface: 'this `object-tree`', + history: objectBlockHistory('object-tree'), + guidanceSets: OBJECT_TREE_FLAT_CONFIG_GUIDANCE, + aliases: FILTERS_TO_FILTER, +}, { + /** + * ⚠️ Optional for a DIFFERENT reason than its siblings, and the reason is + * measured rather than inherited. `object-grid` / `object-kanban` / + * `object-calendar` / `object-map` / `object-gantt` all say "the + * component-level `dataSource` binding can supply the object instead"; that + * holds for them because each registers through `ElementDataSourceGate`, + * which lowers the spec binding onto `objectName` before the renderer sees + * the node. `plugin-tree/src/index.tsx` does NOT: at the `.objectui-sha` pin + * `53ded82b` its registry shell has ZERO hits for that wiring, against 7 + * each in `plugin-map`, `plugin-gantt`, `plugin-grid` and `plugin-calendar` + * — four controls, so the zero discriminates. Its shell pulls a `dataSource` + * off the schema context and hands it down as the data ADAPTER; nothing on + * that path writes an object name. + * + * What really makes it optional is the record-source ladder's first two + * rungs (objectui#6939): `data` can name the object itself, and `staticData` + * needs no object at all, so a tree authored on either never reads this key. + */ + objectName: z.string().optional() + .describe("Object this tree binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because either of the first two rungs resolves the source without it: `data` can name the object itself (`{ provider: 'object', object }`) and `staticData` needs none. ⚠️ NOT supplied by the component-level `dataSource` binding the sibling blocks name — this renderer registers no such gate"), + /** + * Data source binding — `ViewDataSchema`, the object arm rung 1 returns + * verbatim. Declared from the READ POINT (`:359`), not from objectui's + * mirror, which carries no `data` on either face at this pin. + */ + data: ViewDataSchema.optional() + .describe("Data source binding (ViewDataSchema — discriminated on `provider`: object | api | value | schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused"), + staticData: z.array(z.unknown()).optional() + .describe("Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config"), + /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-tree`', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() + .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), + tree: TreeConfigSchema.optional() + .describe('Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: { parentField?, labelField?, fields?, defaultExpandedDepth? }. `parentField` auto-detects from the object schema when omitted'), + navigation: z.unknown().optional() + .describe('Row-click navigation config ({ mode: page | drawer | modal | split | popover | none })'), +})); +/** Author state (ADR-0122: the bare name is the author state). */ +export type ObjectTreeProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state — `filter` carries + * `z.array(ViewFilterRuleSchema)` (`operator` normalizes on parse) and `data` + * carries `ViewDataSchema`, so this block leaves the type-alias convention pin's + * default-free family the way `object-grid` did. + */ +export type ObjectTreePropsParsed = z.infer; + /** * ---------------------------------------------------------------------- * Component Props Map @@ -3299,6 +3723,18 @@ export const ComponentPropsMap = { 'object-calendar': ObjectCalendarPropsSchema, 'object-form': ObjectFormPropsSchema, 'object-master-detail-form': ObjectMasterDetailFormPropsSchema, + // #18305, executing the objectui#8348 ruling 「8348 以协议为准」 (batch #83) + // and batch #136 item 3 (Q1-C). The three blocks this section enumerated + // past: they were not ruled out, they were simply never measured, so the + // #5068 gate skipped them and objectui's OWN mirror stood in as the + // authority for map and gantt while tree's rung-1 `data` read stayed + // undeclared on every face. Key sets measured from the renderers' read + // points at the `.objectui-sha` pin `53ded82b` — per-block citations in each + // schema's header, including what each block reads and deliberately does NOT + // declare. + 'object-map': ObjectMapPropsSchema, + 'object-gantt': ObjectGanttPropsSchema, + 'object-tree': ObjectTreePropsSchema, } as const; /** diff --git a/packages/spec/src/ui/filter-rule-array-guidance.test.ts b/packages/spec/src/ui/filter-rule-array-guidance.test.ts index b5449b73e62..d50d3a36f04 100644 --- a/packages/spec/src/ui/filter-rule-array-guidance.test.ts +++ b/packages/spec/src/ui/filter-rule-array-guidance.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The seven converged rule-array `filter` doors name the new spelling when + * The ten converged rule-array `filter` doors name the new spelling when * they refuse the old one. * * Seven doors converged on `z.array(ViewFilterRuleSchema)` (the objectui#6206 @@ -39,8 +39,17 @@ import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; const RECORD_FORM = { status: 'active' } as const; /** - * The seven doors, each with enough sibling props to reach a clean reading — + * The ten doors, each with enough sibling props to reach a clean reading — * the other required keys are filled so the only issue under test is `filter`. + * + * Seven at the convergence; `object-map`, `object-gantt` and `object-tree` + * joined at #18305, when those three blocks got their `ComponentPropsMap` rows. + * They were BORN on the ruled orthography rather than converged onto it, so + * their §1 reading is the same one and their history sentence is not — what + * this file pins of them is identical: the refusal carries the prescription, + * the array author's element issues are still their own, and the surface each + * one names is the surface its own declaration registered. A door added + * without its row here fails §4's count, which is the point of the count. */ const DOORS: readonly { readonly name: string; @@ -78,6 +87,24 @@ const DOORS: readonly { migration: 'element-data-source-and-object-block-filter-rule-array', parse: (filter) => ComponentPropsMap['object-calendar'].safeParse({ objectName: 'task', filter }), }, + { + name: "ComponentPropsMap['object-map'].filter", + surface: 'this `object-map`', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ComponentPropsMap['object-map'].safeParse({ objectName: 'task', filter }), + }, + { + name: "ComponentPropsMap['object-gantt'].filter", + surface: 'this `object-gantt`', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ComponentPropsMap['object-gantt'].safeParse({ objectName: 'task', filter }), + }, + { + name: "ComponentPropsMap['object-tree'].filter", + surface: 'this `object-tree`', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ComponentPropsMap['object-tree'].safeParse({ objectName: 'task', filter }), + }, { name: "ComponentPropsMap['element:number'].filter", surface: 'this `element:number`',