diff --git a/.changeset/11168-map-style-precedence.md b/.changeset/11168-map-style-precedence.md new file mode 100644 index 0000000000..bcab4026a0 --- /dev/null +++ b/.changeset/11168-map-style-precedence.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-map': patch +--- + +`object-map` reads `mapStyle` before `map.style`, as `@objectstack/spec`'s `object-map` row says in `mapStyle`'s own description ("Read before `map.style`"). This is objectui#11168 slice 3, under the seat's ruling: the renderer follows the spec. + +**Behaviour change.** A map that writes both a node-level `mapStyle` and a `style` inside its `map` block now draws `mapStyle`. Until now, the block's `style` won on that path. A map writing only one of the two, or neither, draws exactly what it drew before. The map path with no `map` block already read `mapStyle` first. + +No authored producer in objectui or objectstack writes both. The only producers of `mapStyle` are the `ListView` / `ObjectView` flatteners, and they write it on the path with no `map` block. So no stored document changes what it draws. The `object-map` registration publishes the spec's description for `mapStyle` verbatim. diff --git a/.changeset/11168-object-form-columns-label.md b/.changeset/11168-object-form-columns-label.md new file mode 100644 index 0000000000..5397a0d7eb --- /dev/null +++ b/.changeset/11168-object-form-columns-label.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The page-block inspector labelled the `object-form` `columns` field "Columns (grid layout)" in English and 「列数(网格布局)」 in Chinese. That pointed at the `grid` form layout, which objectui#7759 Group C retires. The field does not depend on `layout`. Measured through the real `SchemaRenderer`, `columns` sets how many columns the form's field grid has under either layout, and the spec row says the same: "Number of field columns (multi-column forms), honoured under either `layout`". `1` stacks the fields, `2` to `4` give that many columns at the responsive breakpoints, and leaving it out lets the form infer the count from the number of fields. The labels now read "Field columns (1–4)" and 「字段列数(1–4)」 (objectui#11168 slice 3). The field, its key and what it writes are unchanged. diff --git a/.changeset/11168-object-map-inputs.md b/.changeset/11168-object-map-inputs.md new file mode 100644 index 0000000000..ac6d55730d --- /dev/null +++ b/.changeset/11168-object-map-inputs.md @@ -0,0 +1,11 @@ +--- +'@object-ui/plugin-map': minor +--- + +`object-map` publishes the three keys its `@objectstack/spec` 17.5.0 row declares and its registration left out: `mapStyle`, `navigation` and `enableClustering` (objectui#11168 slice 3, objectui#11111 decision 3 = B). Each was measured on the real renderer first, and the renderer honours all three. The page validator used to report each one as an unknown prop. + +- `mapStyle` replaces the demo tiles. Its description is the spec row's own, verbatim: it is read before `map.style`. The renderer now reads it that way too; the `mapStyle` precedence entry in this release states that change. +- `enableClustering: true` clusters nearby markers. Without the key, the map clusters only above 100 markers, and `false` turns clustering off at any count. +- On `navigation`, `drawer`, `modal` and `popover` open the marker's record. `new_window`, or `openNewTab: true`, opens the record page in a new tab. `page` and a block without `mode` open the record page of the map's `objectName` through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views). Under a host that publishes none, or on a map that names no `objectName`, they open nothing. On a map that no parent view navigates for, a click also opens nothing when the key is absent or the mode is `none` or `split`. + +The `map` input's description now lists its members. Apart from the `mapStyle` precedence, rendering is unchanged. diff --git a/.changeset/11168-object-tree-inputs.md b/.changeset/11168-object-tree-inputs.md new file mode 100644 index 0000000000..3ef2d834d1 --- /dev/null +++ b/.changeset/11168-object-tree-inputs.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-tree': minor +--- + +`object-tree` publishes the keys its `@objectstack/spec` 17.5.0 row declares and its renderer honours (objectui#11168 slice 3, objectui#11111 decision 3 = B). Each key was measured on the real renderer first. + +- `data`, `staticData`, `filter` and `navigation` are now registration inputs on both tags (`object-tree` and `view:tree`). The page validator used to report each as an unknown prop, even though the tree reads it. Each description says what the tree does with the key, including the cases that draw nothing: the `api` and `schema` providers under `data`, and an absent `navigation`. On `navigation`, `page` and a block without `mode` open a record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views). The object is `data.object` when `data` is the object provider, so it wins when both are written, and the tree's `objectName` otherwise. Under a host that publishes none, or on a tree that names neither (inline rows with no `objectName`), they open nothing. +- `objectName` is no longer a required input. The record source is one of `data`, `staticData` and `objectName`. A tree on inline rows never reads the object name, and the validator used to raise `missing-required-prop` on a tree that draws. +- The `tree` input's description now names its four members and what each does. +- `ObjectTree` reads `navigation` without a cast, because the node type declares it. + +What the tree draws and what a click opens are unchanged. diff --git a/.changeset/11168-timeline-navigation-wording.md b/.changeset/11168-timeline-navigation-wording.md new file mode 100644 index 0000000000..345b464877 --- /dev/null +++ b/.changeset/11168-timeline-navigation-wording.md @@ -0,0 +1,11 @@ +--- +'@object-ui/plugin-timeline': patch +--- + +The `object-timeline` / `view:timeline` `navigation` input description had three wording errors, and all three are corrected (objectui#11168 slice 3, from the contract record on objectui#8654 and the one on PR #11335). + +- The reason `split` opens nothing (the timeline hands the split shell no main panel) was attached to `page` and `none` as well. It now applies to `split` alone. +- The description said `openNewTab: true` "outranks the mode". That does not hold for `none`, which is checked first, as `preventNavigation` is. The description now says `openNewTab` outranks every mode except `none`. +- The description said `page`, and a block without `mode`, open nothing on a timeline no parent view navigates for. Since objectui#11293 they open the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views), and they open nothing only under a host that publishes none. The description now says so. An absent key still opens nothing. + +The README and the docs page's navigation table and callout are corrected the same way. Behaviour is unchanged. diff --git a/.changeset/11168-types-object-tree-mirror.md b/.changeset/11168-types-object-tree-mirror.md new file mode 100644 index 0000000000..870af4f927 --- /dev/null +++ b/.changeset/11168-types-object-tree-mirror.md @@ -0,0 +1,15 @@ +--- +'@object-ui/types': minor +--- + +`ObjectTreeSchema` mirrors the `object-tree` row of `@objectstack/spec` 17.5.0 (objectui#11168 slice 3). The change applies to both faces, TypeScript and zod. + +- `objectName` is optional. The zod face ends in the record-source refinement the map and gantt mirrors carry: a node needs `data`, `staticData` or `objectName`. `objectui validate` used to refuse a `staticData`-only tree with `invalid_type` at `objectName`, even though the tree draws it. It now accepts that tree. A tree with no record source is refused once, at the root, with `RECORD_SOURCE_REQUIRED`. The node's `dataSource` binding does not count, because this block's registration is not gate-wrapped and no binding reaches the tree. +- `data` (`ViewData`), `staticData`, `tree` (the spec's `TreeConfig`, by reference) and `navigation` (the spec's `NavigationConfig`, by reference) are declared. + +⚠️ This narrows what is accepted. Until now these keys rode the node's passthrough, so any value was accepted. Each is now judged: +- a bare array under `data` is a type error; +- a misspelled member inside `tree` is refused; +- an unknown `navigation.mode` is refused. + +The fixed group ships the change as `minor`. diff --git a/.changeset/11168-ui-action-size.md b/.changeset/11168-ui-action-size.md new file mode 100644 index 0000000000..b104af0dc7 --- /dev/null +++ b/.changeset/11168-ui-action-size.md @@ -0,0 +1,7 @@ +--- +'@object-ui/types': minor +--- + +`UIActionSchema.size` takes the `action:button` row's vocabulary by reference (objectui#11168 slice 3). Before this, the type was `'sm' | 'md' | 'lg'`. That made `size: 'default'` and `size: 'icon'` a TypeScript error, although `@objectstack/spec` 17.5.0's `action:button` row accepts both, the `action:button` registration publishes them, and the Button primitive draws them. + +The type is also the member type of the `action:group`, `action:menu` and `action:bar` lists. Measured through the real `SchemaRenderer`, a group member draws each of the five sizes exactly as an `action:button` does, and a member's own `md` renders as `default`. A size outside the five is still refused. diff --git a/.changeset/11168-view-tabs-layout-values.md b/.changeset/11168-view-tabs-layout-values.md new file mode 100644 index 0000000000..7ff102d938 --- /dev/null +++ b/.changeset/11168-view-tabs-layout-values.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-view': patch +--- + +The README's "View tabs" section listed `form.layout` as `vertical | horizontal | inline | grid`. It now lists `vertical | horizontal`, the two values the form layout keeps after objectui#7759 Group C (objectui#11168 slice 3). The docs page carries the same line. Only the README text changes; the package's code is untouched. diff --git a/.changeset/8253-export-tree-view-config.md b/.changeset/8253-export-tree-view-config.md index dc19f0acbe..e9861e9bf1 100644 --- a/.changeset/8253-export-tree-view-config.md +++ b/.changeset/8253-export-tree-view-config.md @@ -65,3 +65,13 @@ misspelled key instead of a view that silently ignores it. ⛔ This does not make `tree` an authorable view type. objectui#5321 is untouched: the block is host config, written by a host and never by a document author, and the authored node remains the flat `ObjectTreeSchema`. + +**Note, 2026-10-01 (objectui#11168 slice 3, shipping in this same release).** +The last sentence above calls the authored node "the flat `ObjectTreeSchema`". +`@objectstack/spec` 17.5.0 gave `object-tree` an element row whose `tree` block +carries `parentField`, `labelField`, `fields` and `defaultExpandedDepth`, and +`ObjectTreeSchema` now declares that block by reference, so an authored tree +writes those keys under `tree` rather than flat on the node. The flat spellings +`ObjectTreeSchema` still carries are the form a host composes. What this entry +says about the host `tree` view config, `TreeViewConfig` and objectui#5321 is +unchanged. diff --git a/.changeset/8654-timeline-navigation.md b/.changeset/8654-timeline-navigation.md index f9df6339ad..e93fe7c869 100644 --- a/.changeset/8654-timeline-navigation.md +++ b/.changeset/8654-timeline-navigation.md @@ -9,3 +9,14 @@ - An authored `{ "type": "object-timeline", "properties": { … } }` document was already judged by the spec row: a bad `mode` is refused at `properties.navigation.mode`, and a member the spec does not declare inside the block is refused. That is unchanged and now pinned. What a click does with each member is unchanged. `drawer`, `modal` and `popover` open the entry's record in that overlay, and `new_window` or `openNewTab: true` opens the record page in a new tab. Three things open nothing on a timeline that no parent view navigates for: an absent key, `page` (also what a block without `mode` resolves to), and `split`. The published input description says so. + +**Note, 2026-10-01 (objectui#11168 slice 3, shipping in this same release).** +The last paragraph above says `page`, and a block without `mode`, open nothing +on a timeline that no parent view navigates for, and that the published input +description says so. objectui#11293 changed that in this same release: a +`page` click with no `onNavigate` now goes to the record navigator the host +publishes, which the console publishes on its custom pages, record pages and +list views. Under such a host `page` and a block without `mode` open the record +page. They open nothing only under a host that publishes none, such as an +embedded renderer. The input description now says so (objectui#11168). An +absent key and `split` still open nothing. diff --git a/.changeset/8655-object-tree-props-schema-typed.md b/.changeset/8655-object-tree-props-schema-typed.md index 9afc01e06d..c7f8054e6f 100644 --- a/.changeset/8655-object-tree-props-schema-typed.md +++ b/.changeset/8655-object-tree-props-schema-typed.md @@ -50,3 +50,18 @@ needs a producer census a text search cannot make. `navigation` belongs to a blocked card and is ledgered, ⛔ not ruled. Nothing in `@object-ui/types` is declared, widened or narrowed by this change. + +**Note, 2026-10-01 (objectui#11168 slice 3, shipping in this same release).** +The `navigation` and `tree` readings above changed after this entry was written. +`@objectstack/spec` 17.5.0 gave `object-tree` an element row, and that row +declares both keys, so both verdicts here were re-run on that contract. +- `navigation` is DECLARED on `ObjectTreeSchema` as the spec's + `NavigationConfig`, and the cast this entry says "stays" is gone. +- `tree` is DECLARED as the spec's `TreeConfig`, by reference. Mirroring the + element row is not the fork described above. +- `ObjectTreeSchema` also declares `data` as `ViewData` (not `BaseSchema`'s + untyped `data`) and `staticData`, and `objectName` is optional. + +So the sentences above that say these keys are undeclared describe the tree at +objectui#8655, not the code in this release. See that slice's changesets for +`@object-ui/types` and `@object-ui/plugin-tree`. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index d2ac84c5d7..ba32884196 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -300,6 +300,13 @@ import '../register-plugins'; // name, instead of quietly rejoining the blind spot. import '@object-ui/plugin-kanban'; import '@object-ui/plugin-calendar'; +// objectui#11168 slice 3 — the same remedy for the lazily registered blocks +// `@objectstack/spec` 17.5.0 newly carries, one block at a time: each import +// below is what takes a block out of `UNJUDGED_SPEC_BLOCKS` (where the bump +// booked it OWED) and puts it under both directions. `MINOR_17_5_LOADED_BLOCKS` +// names them. +import '@object-ui/plugin-tree'; +import '@object-ui/plugin-map'; /** This block's spec props schema, or `undefined` when this pin has none. */ const specSchema = (type: string): unknown => (ComponentPropsMap as Record)[type]; @@ -591,8 +598,9 @@ const LAZY_REGISTERED_BLOCKS = [ * The six blocks `@objectstack/spec` 17.5.0 newly carries that this file JUDGES * (objectui#11073): the map went from 45 entries to 55. The other four new * entries — `object-gantt`, `object-map`, `object-timeline`, `object-tree` — are - * registered lazily and not loaded here, and are booked in - * `UNJUDGED_SPEC_BLOCKS` to objectui#11168 instead (see `OWED_TO` below). + * registered lazily, and the bump booked them in `UNJUDGED_SPEC_BLOCKS` to + * objectui#11168 instead (see `OWED_TO` below). That card's slice 3 loads them + * one block at a time; each loaded block moves to `MINOR_17_5_LOADED_BLOCKS`. * * Same shape as `GA_ONLY_BLOCKS` / `MINOR_17_1_BLOCKS`: this repo registered all * six with `inputs` before the spec described them, so what moved at the bump @@ -611,12 +619,28 @@ const MINOR_17_5_BLOCKS = [ /** Does the installed `@objectstack/spec` carry the 17.5.0 set? `every`, for the reason `specCarriesGaBlocks` gives. */ const specCarries175Blocks = MINOR_17_5_BLOCKS.every((type) => type in ComponentPropsMap); +/** + * The lazily registered 17.5.0 blocks this file LOADS and judges — objectui#11168 + * slice 3, which takes them out of `UNJUDGED_SPEC_BLOCKS` one block at a time. + * Kept as its own group for the reason `LAZY_REGISTERED_BLOCKS` is: a reader + * asking why the population moved without a pin bump gets the answer here. Each + * entry is a block whose plugin package is imported at the top of this file. + * + * Pin-dependent like `MINOR_17_5_BLOCKS`: the spec carries these rows from + * 17.5.0 on, and they are expected only when it does. + */ +const MINOR_17_5_LOADED_BLOCKS = ['object-map', 'object-tree']; + +/** Does the installed `@objectstack/spec` carry the loaded 17.5.0 blocks? `every`, for the same reason. */ +const specCarries175LoadedBlocks = MINOR_17_5_LOADED_BLOCKS.every((type) => type in ComponentPropsMap); + const EXPECTED_COVERED = [ ...PINNED_EXPECTED_COVERED, ...LAZY_REGISTERED_BLOCKS, ...(specCarriesGaBlocks ? GA_ONLY_BLOCKS : []), ...(specCarries171Blocks ? MINOR_17_1_BLOCKS : []), ...(specCarries175Blocks ? MINOR_17_5_BLOCKS : []), + ...(specCarries175LoadedBlocks ? MINOR_17_5_LOADED_BLOCKS : []), ].sort(); /** @@ -757,7 +781,7 @@ const owedIdsOf = (ledger: Record): string[] => * card to land sets all five to 0. */ const OBJECTUI_11111_LEDGER_CAPS = { - unjudgedBlocks: 4, // objectui#11168: object-gantt, object-map, object-timeline, object-tree + unjudgedBlocks: 2, // objectui#11168: object-gantt, object-timeline (slice 3 loaded and judged object-map and object-tree) offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name unpublishedKeys: 3, // objectui#11168: 3 (action:button endpoint + undoable, action:icon endpoint); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout @@ -854,27 +878,22 @@ const UNJUDGED_SPEC_BLOCKS: Record = { 'mcp:connect-agent': 'EMPTY SPEC SHAPE. New in `@objectstack/spec` 17.3.0, which declares it with no top-level key at all. Registered propless by `@object-ui/app-shell` (`src/console/connect/ConnectAgentWidget.tsx`), outside this file\'s import graph; the empty shape is the load-bearing half either way. objectui#7122, ledger objectui#8176.', // The fourth class, and the only one that is not mechanically "unjudgeable": - // OWED. These four are new in `@objectstack/spec` 17.5.0, registered with + // OWED. These are new in `@objectstack/spec` 17.5.0, registered with // `registerLazy` by the console (`register-plugins.ts`) and not loaded by this // file, so their declarations are real and unread. The remedy the // mechanism test names — an eager import — would judge them, and judging them // is objectui#11168's work, not the bump's (objectui#11111 decision 3 = B). + // The bump booked four; `object-map` and `object-tree` were struck by + // objectui#11168 slice 3, which loads both at the top of this file and + // published the keys their renderers honour (`MINOR_17_5_LOADED_BLOCKS`). 'object-gantt': OWED_TO( 'objectui#11168', 'REGISTERED LAZILY, NOT LOADED HERE: `object-gantt` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-gantt` in this file is what judges it.', ), - 'object-map': OWED_TO( - 'objectui#11168', - 'REGISTERED LAZILY, NOT LOADED HERE: `object-map` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-map` in this file is what judges it.', - ), 'object-timeline': OWED_TO( 'objectui#11168', 'REGISTERED LAZILY, NOT LOADED HERE: `object-timeline` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-timeline` in this file is what judges it.', ), - 'object-tree': OWED_TO( - 'objectui#11168', - 'REGISTERED LAZILY, NOT LOADED HERE: `object-tree` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-tree` in this file is what judges it.', - ), 'user:profile': 'RETIRED UPSTREAM. `@objectstack/spec` 17.3.0 retired it by name (objectstack#14159, landed by objectstack#15112): out of `PageComponentType`, named in `RETIRED_PAGE_COMPONENT_TYPES`, and its row `retiredComponentProps` — a `never` that refuses every props bag, `{}` included — so there is no authoring surface for either direction to judge. The same release\'s reconciliation here (objectui#7122) took it out of `PROTOCOL_COMPONENTS` in `@object-ui/components` `renderers/placeholders.tsx`, so not even the placeholder scaffold stands in for it. objectui#10033, ledger objectui#8176.', }; @@ -2679,7 +2698,7 @@ const MEMBER_PINS: Record = { }, 'object-calendar.calendar': { file: 'packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx', - pins: 'Members are FIELD NAMES the calendar projects onto an event: `startDateField` and `endDateField` name the record fields that become the event\'s `start`/`end` (an unauthored `endDateField` leaves `end` undefined rather than inventing one), and `titleField` OUTRANKS the object\'s own default display-name resolution (`getRecordDisplayName`) rather than only supplementing it. The sharp claim is PRECEDENCE: `getCalendarConfig` returns `schema.calendar` OUTRIGHT the moment it is set — never merged against the flat legacy spelling (`startDateField/endDateField/titleField/colorField` authored directly on the schema) it falls back to only when `calendar` is absent — so a schema carrying BOTH, with the two disagreeing, is read entirely from the nested object and not at all from the flat siblings, even for the keys the nested object leaves unset. `colorField`\'s own resolution ladder and `allDayField` (an objectui-local extra key inside the same object) are each pinned narrowly already and are not re-asserted here: `ObjectCalendar.colorFieldLadder-7243.test.tsx`, `ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`. The spec side is a `strictObject` of exactly the four documented keys, so it fixes the NAMES but nothing about how they are used once read — the read site is the whole member contract (objectui#8071).', + pins: 'Members are FIELD NAMES the calendar projects onto an event: `startDateField` and `endDateField` name the record fields that become the event\'s `start`/`end` (an unauthored `endDateField` leaves `end` undefined rather than inventing one), and `titleField` OUTRANKS the object\'s own default display-name resolution (`getRecordDisplayName`) rather than only supplementing it. The sharp claim is PRECEDENCE: `getCalendarConfig` returns `schema.calendar` OUTRIGHT the moment it is set — never merged against the flat legacy spelling (`startDateField/endDateField/titleField/colorField` authored directly on the schema) it falls back to only when `calendar` is absent — so a schema carrying BOTH, with the two disagreeing, is read entirely from the nested object and not at all from the flat siblings, even for the keys the nested object leaves unset. `colorField`\'s own resolution ladder and `allDayField` are each pinned narrowly already and are not re-asserted here: `ObjectCalendar.colorFieldLadder-7243.test.tsx`, `ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`. The spec side is a strict object of exactly the five documented keys — `allDayField` among them since `@objectstack/spec` 17.5.0, objectui-local before that (text corrected by objectui#11168 slice 3) — so it fixes the NAMES but nothing about how they are used once read: the read site is the whole member contract (objectui#8071).', }, 'object-calendar.data': { file: 'packages/plugin-calendar/src/__tests__/ObjectCalendar.recordSourceMembers-8314.test.tsx', @@ -2949,6 +2968,62 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-dashboard/src/__tests__/objectMetricTrendMembers-8071.test.tsx', pins: 'The static badge\'s three members, each pinned on an observable only that member can move, driven through the registered block. `value` is painted as a PERCENTAGE — the `%` is the badge\'s own, asserted beside a metric whose `format` and `suffix` shape the NUMBER differently. `direction` chooses the glyph and is pinned as a SET (`up` / `down` / `neutral`, plus the OMITTED arm, which still paints the value and draws no glyph at all), so "renders an up arrow" cannot pass on a renderer that draws one unconditionally; an off-list member is asserted not to reach the badge, and a tile with neither `trend` nor `description` draws no badge row — the file\'s non-vacuity floor. The two rows with real semantics to get wrong: the tile-level `description` OUTRANKS `trend.label` in the one caption slot they share, so a tile that authors both silently loses the trend\'s own words; and a `compareTo`-DERIVED trend REPLACES the authored badge outright (`derivedTrend ?? trend`) rather than merging with it. That override is pinned against a no-`compareTo` control on the SAME schema, where 120-vs-100 derives +20% up while the authored badge says 99% down, so neither arm can pass by painting the other\'s numbers — which is what makes the registration\'s own sentence ("Use `compareTo` instead when the trend should be computed from data") true rather than advisory. The spec row is `z.unknown()`, so the read site is the whole member contract. New file (objectui#8071 slice 9).', }, + // objectui#11168 slice 3 — `object-map`, newly judged once this file loads + // `@object-ui/plugin-map`. Six keys share ONE new file, every row through the + // real `SchemaRenderer` and this package's own registration with MapLibre + // stubbed; `dataSource` PROMOTES a pre-existing file, read end to end first. + 'object-map.map': { + file: 'packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx', + pins: 'Members are FIELD NAMES and the camera, each on the observable only it moves: `latitudeField` / `longitudeField` plot one marker per record, with a binding that names no number-holding field plotting none (the control); `locationField` reads a `"lat,lng"` string, a `{ lat, lng }` object and a `[lat, lng]` pair; `titleField` and `descriptionField` name the popup\'s heading and line after a marker click, a different `titleField` changing the heading; `zoom` and `center` (`[latitude, longitude]`) ARE the initial camera, where an absent camera fits the markers (`bounds`). `style` is pinned with `mapStyle`, both ways: the node\'s `mapStyle` is read BEFORE `map.style`, as the spec row\'s own describe says ("Read before `map.style`") and the seat ruled (option A) — both written draws `mapStyle`, either alone draws itself, neither draws the demo tiles. The spec block fixes names and value kinds; how each is used is the read site (objectui#11168 slice 3).', + }, + 'object-map.data': { + file: 'packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx', + pins: 'Members are the `{ provider, … }` configurations, asserted on markers plotted AND queries issued: `value` plots its `items` and queries nothing, `object` queries THAT object and plots what it returns, and `api` plots nothing and queries nothing, as the description says. The ladder POSITION is pinned too: a `data` configuration wins over `staticData` and `objectName` on the same node. The bare-array spelling is objectui#8348\'s pin (`ObjectMap.dataArmSpecRow-8348.test.tsx`) and is not re-asserted (objectui#11168 slice 3).', + }, + 'object-map.staticData': { + file: 'packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx', + pins: 'Members are RECORDS, plotted by the `map` block\'s fields: a record without readable coordinates is skipped, never placed at an invented point. The POSITION claim is pinned against a live control: a map carrying `staticData` and `objectName` plots the inline rows and never queries, while the same node without the rows does query. The spec row is `z.array(z.unknown())`, so the read site is the whole member contract (objectui#11168 slice 3).', + }, + 'object-map.filter': { + file: 'packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx', + pins: 'Members are `{ field, operator, value }` rules: a rule narrows the inline records plotted, `operator` is read (`not_equals` plots the complement of `equals`), and on the object query the rule reaches `$filter` with its members unchanged, against a control with no `filter` whose query carries no `$filter`. The binding\'s filter AND-ing is `object-map.dataSource`\'s pin (objectui#11168 slice 3).', + }, + 'object-map.sort': { + file: 'packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx', + pins: 'Members are `{ field, order }`, and those two keys are all the map reads inside one: each member lowers to `field -> direction` on `$orderby`, in AUTHORED order, and an omitted `order` reads ascending rather than dropping the member; with no `sort` the query carries no ordering (the control). ⛔ Not an identity pin: the shared `convertSortToQueryParams` sink builds a new map (objectui#11168 slice 3).', + }, + 'object-map.dataSource': { + file: 'packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx', + pins: 'The per-element binding\'s members as this block reads them, through the REAL `ElementDataSourceGate` + renderer pair: `object` is the object queried, a named `view`\'s `filter` / `sort` reach the fetch as `$filter` / `$orderby`, the binding\'s own `filter` AND-combines with the view\'s rather than replacing it, a `sort` member missing `order` reads ascending (objectui#4022), an unresolvable `view` reports instead of fetching the whole object, and an authored `limit` never reaches `$top`, which stays the platform ceiling (`OBJECT_MAP_DATA_SOURCE` maps `filter` and `sort` only). A map with no binding behaves exactly as before one existed. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), so the declaration says nothing about members. Pre-existing file (objectstack#7121), promoted after being read end to end (objectui#11168 slice 3).', + }, + 'object-map.navigation': { + file: 'packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx', + pins: 'The members of the spec\'s `NavigationConfigSchema` block the map reads, each driven through a real marker click: `drawer` (the LIT CONTROL), `modal` and `popover` open the marker\'s record; `split` opens NOTHING, because the map hands the split shell no main panel; `none` and `preventNavigation` open nothing, the flag outranking an overlay mode; `new_window` and `openNewTab` open `/{objectName}/record/{id}` in a new tab, `openNewTab` outranking `drawer` but NOT `none`; `size` / `width` are one width decision (`width` wins, a bucket resolves, `auto` lands where an unsized drawer does); and a parent view\'s click handler outranks the whole key. An ABSENT key opens nothing, with the host\'s record navigator mounted and without. `page`, and a block without `mode`, are pinned both ways: under a host that publishes its record navigator (`RelatedRecordActionsContext`) they open the record page of the map\'s `objectName` through it, with no overlay and no tab, and `drawer` stays the lit control there; with no host navigator, or on a map that names no `objectName`, they open nothing (objectui#11168 slice 3, objectui#11293).', + }, + // objectui#11168 slice 3 — `object-tree`, newly judged once this file loads + // `@object-ui/plugin-tree`. ONE file pins the five structured keys, every row + // through the real `SchemaRenderer` and this package's own registration, with + // a control per positive row; each claim below names its rows. + 'object-tree.tree': { + file: 'packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx', + pins: 'Members are FIELD NAMES the tree draws by, each on the observable only it moves: `parentField` nests a record one level under the record its value names (a field holding no parent id leaves every record a root — the control), `labelField` picks the text of the first column with an absent member reading `name`, `fields` adds one flat column per member after the label (header and cell, against a label-only control), and `defaultExpandedDepth: 0` draws roots only where an absent member opens every level. Measured and pinned, not endorsed: a FLAT key of the same name on the node outranks the member (`getTreeConfig` reads `schema.parentField ?? tree.parentField`); the spec row does not declare the flat keys and the registration does not publish them. The spec block is a strict four-key object, so it fixes the NAMES but nothing about how each is used (objectui#11168 slice 3).', + }, + 'object-tree.data': { + file: 'packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx', + pins: 'Members are the `{ provider, … }` configurations, each asserted on the rows drawn AND the query issued: `{ provider: \'value\', items }` draws those records and queries nothing, `{ provider: \'object\', object }` queries THAT object and draws what it returns, and `api` and `schema` — both accepted by the spec row — draw nothing and query nothing, which is what the description says. The ladder POSITION is pinned too: a `data` configuration wins over `staticData` and over `objectName` on the same node. The bare-array spelling is objectui#8348\'s pin (`ObjectTree.dataArmSpecRow-8348.test.tsx`) and is not re-asserted (objectui#11168 slice 3).', + }, + 'object-tree.staticData': { + file: 'packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx', + pins: 'Members are RECORDS, and the keys read inside one are the `tree` block\'s fields: each record is placed under the record its parent-id value names, and one naming no record is drawn as a ROOT rather than dropped. The POSITION claim the description makes is pinned against a live control: a tree carrying both `staticData` and `objectName` draws the inline rows and never queries, while the same node without the rows does query, so "never queries" cannot read as a harness that cannot. The spec row is `z.array(z.unknown())`, so the read site is the whole member contract (objectui#11168 slice 3).', + }, + 'object-tree.filter': { + file: 'packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx', + pins: 'Members are `{ field, operator, value }` rules: a rule narrows the inline records drawn, `operator` is read (`not_equals` draws the complement of `equals`), a record whose parent the filter removed is drawn as a ROOT rather than dropped (the description says so), and on the object query the rule reaches `$filter` with its members unchanged — against a control with no `filter`, where the query carries no `$filter` and every inline row is drawn. Context-token resolution is shared machinery and is pinned where it lives, not here (objectui#11168 slice 3).', + }, + 'object-tree.navigation': { + file: 'packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx', + pins: 'The members of the spec\'s `NavigationConfigSchema` block the tree reads, each driven through a real row click and asserted on what the click DID: `drawer` (the LIT CONTROL), `modal` and `popover` open the row\'s record, and `split` opens it BESIDE the tree, which stays drawn (unlike the timeline, the tree hands the split shell its own view); `none` and `preventNavigation` open nothing, the flag outranking an overlay mode; `new_window` and `openNewTab` open `/{objectName}/record/{id}` in a new tab, `openNewTab` outranking `page` and `drawer` but NOT `none`, which is read first; and `size` / `width` are one width decision (`width` wins, a bucket resolves, `auto` lands where an unsized drawer does). A parent view\'s click handler outranks the whole key. An ABSENT key opens nothing, with the host\'s record navigator mounted and without. `page`, and a block without `mode`, are pinned both ways: under a host that publishes its record navigator (`RelatedRecordActionsContext`) they open a record page through it, with no overlay and no tab, and `drawer` stays the lit control there; with no host navigator, or on a tree that names neither `data.object` nor `objectName` (inline rows alone), they open nothing. The object opened is `data.object` when `data` is the object provider, both with no `objectName` and when both are written (the rows\' object wins), and the node\'s `objectName` otherwise (objectui#11168 slice 3, objectui#11293).', + }, 'page:accordion.items': { file: 'packages/components/src/__tests__/pageAccordionItemMembers-8071.test.tsx', pins: 'Which member of one panel definition becomes which part of the rendered accordion — the four `PageAccordionItem` members (`label`, `icon`, `collapsed`, `children`) asserted as a SET through the real renderer, which nothing did before: `label` becomes the trigger\'s accessible name and `children` the panel BODY, asserted against each other so a renderer painting the wrong one cannot pass. `collapsed` is the reading with real semantics to get wrong and it is strictly `=== false`: `collapsed: false` OPENS a panel while `collapsed: true` AND an omitted `collapsed` both leave it shut — the omitted-key arm is the control an "obvious" edit to `!it.collapsed` breaks, and a no-opener fixture keeps the two shut rows from passing on a renderer that opens nothing. The single/multiple split is pinned on the SAME items so only `allowMultiple` varies: single mode takes `defaultOpen[0]` and drops later openers, multiple mode opens them all without opening panels that never asked. `icon` is covered narrowly on purpose — `page-accordion-icon.test.tsx` (objectui#4721) is its pin and is not re-litigated. New file (objectui#8071 slice 7).', @@ -4204,10 +4279,15 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // registered lazily and unloaded here, booked OWED to objectui#11168 under // objectui#11111 decision 3 = B (9 -> 13). `registeredPropless` does not // move. 35 + 7 + 13 = 55. + // + // objectui#11168 slice 3 moves blocks ACROSS the partition, never into or + // out of it: each block it loads leaves the ledger and is judged + // (`MINOR_17_5_LOADED_BLOCKS`). `object-tree` and `object-map`: 35 -> 37 + // judged, 13 -> 11 ledgered. 37 + 7 + 11 = 55. specCarried: 55, - judged: 35, + judged: 37, registeredPropless: 7, - ledgeredUnjudgeable: 13, + ledgeredUnjudgeable: 11, }); // Non-vacuity, stated rather than implied by the numbers above. expect(covered.length).toBeGreaterThan(0); @@ -4234,7 +4314,8 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // 5902351047) booked the four lazily registered blocks 17.5.0 newly carries // OWED to objectui#11168, which loads and judges them. The expectation is // EXACTLY those booked entries, so a fifth unloaded block is still red, and - // the owner card's landing takes this back to `[]`. + // the owner card's landing takes this back to `[]`. Slice 3 loads them one + // block at a time, and each one it loads leaves both this set and the ledger. const registeredButUnloaded = Object.keys(ComponentPropsMap) .filter((type) => ComponentRegistry.hasLazy(type)) .filter((type) => ComponentRegistry.getConfig(type) === undefined) @@ -4326,7 +4407,8 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // come back, `hasLazy` starts answering true again, and the mechanism // assertion above reddens by name — which is the guard this instance pin // does not need to duplicate. - for (const type of LAZY_REGISTERED_BLOCKS) { + // objectui#11168 slice 3's loads are the same instance, one pin later. + for (const type of [...LAZY_REGISTERED_BLOCKS, ...MINOR_17_5_LOADED_BLOCKS]) { expect( (declaredInputs(type) ?? []).length, `${type} is registered without inputs, or its plugin package stopped ` + @@ -5622,6 +5704,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, ]), ), - ).toEqual({ 'objectui#11168': 7, 'objectui#8652': 0, 'objectui#8649': 0 }); + ).toEqual({ 'objectui#11168': 5, 'objectui#8652': 0, 'objectui#8649': 0 }); }); }); diff --git a/content/docs/plugins/plugin-map.mdx b/content/docs/plugins/plugin-map.mdx index 6e8760ff61..262ae1dbea 100644 --- a/content/docs/plugins/plugin-map.mdx +++ b/content/docs/plugins/plugin-map.mdx @@ -126,9 +126,9 @@ const schema: BaseSchema = { filter?: Array, // Query filter, sent as $filter sort?: SortConfig[], // Sort, sent as $orderby map?: ObjectMapConfig, // Map-specific configuration - enableClustering?: boolean, // Cluster nearby markers (auto past 100) - navigation?: NavigationConfig, // Record navigation (drawer/dialog/page) - mapStyle?: string // MapLibre style URL/spec + enableClustering?: boolean, // Cluster nearby markers (auto past 100; false turns it off) + navigation?: NavigationConfig, // What a marker click opens (see Marker click navigation) + mapStyle?: string // MapLibre style URL; read before `map.style` }, onMarkerClick?: (record: any) => void, // A host callback: on the node, in code only className?: string @@ -327,6 +327,30 @@ already hold with the **Value Provider**. ## Event Handling +### Marker click navigation + +`navigation` takes the spec's `NavigationConfig` (`{ mode, size, width, +openNewTab, preventNavigation }`), the block a list view declares. On a map no +parent view navigates for: + +- **Absent**: a marker click opens nothing. This renderer supplies no drawer + default. +- `drawer`, `modal` and `popover` open the marker's record in that overlay. + `split` opens nothing: the map hands the split shell no main panel. +- `new_window` opens `/{objectName}/record/{id}` in a new tab. `none` opens + nothing. +- `page`, and a block without `mode` (it takes the spec's `page` default), open + the record page of the map's `objectName` through the record navigator the + host publishes (the console publishes one on its custom pages, record pages + and list views). Under a host that publishes none, such as an embedded + renderer, or on a map that names no `objectName`, there is no record page to + open and the click opens nothing. +- `preventNavigation: true` opens nothing whatever the mode. `openNewTab: true` + opens the record page in a new tab and outranks every mode except `none`. +- `size` picks the overlay width bucket; the deprecated `width` wins over it. + +A click handler from a parent view (`onRowClick`) outranks the whole key. + ### Marker Click `onMarkerClick` is a function, so it is no member of the `properties` bag (the diff --git a/content/docs/plugins/plugin-timeline.mdx b/content/docs/plugins/plugin-timeline.mdx index 7c2f82c3cf..a8a1135a44 100644 --- a/content/docs/plugins/plugin-timeline.mdx +++ b/content/docs/plugins/plugin-timeline.mdx @@ -206,17 +206,17 @@ reference, `ViewNavigationConfig` in `@object-ui/types`, the type | Member | On an entry click | |--------|-------------------| | `mode: 'drawer'` / `'modal'` / `'popover'` | Opens the entry's record in that overlay | -| `mode: 'new_window'`, or `openNewTab: true` | Opens the record page in a new tab; `openNewTab` outranks the mode | -| `preventNavigation: true`, or `mode: 'none'` | Opens nothing | +| `mode: 'new_window'`, or `openNewTab: true` | Opens the record page in a new tab; `openNewTab` outranks every mode except `none` | +| `mode: 'page'`, or a block without `mode` (the spec's `page` default) | Opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, it opens nothing | +| `preventNavigation: true`, or `mode: 'none'` | Opens nothing, whatever else the block says | | `size` | Sets the overlay width (`sm` … `full`, or `auto` for the default) | A click handler from a parent view outranks the whole key. -Write the block with `mode`. With the key absent, or with a block written -without `mode` (it takes the spec's `page` default), a click opens nothing on a -timeline no parent view navigates for (objectui#11293). `split` opens nothing on -this block either, because the timeline gives the split shell no main panel. +With the key absent a click opens nothing: unlike the board and the calendar, +this renderer supplies no drawer default. `split` opens nothing on this block +either, because the timeline gives the split shell no main panel. ## Variants diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 3c179a74e2..4026b9aa61 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -672,7 +672,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.pageBlock.field.object-form.layout': 'Layout', 'engine.inspector.pageBlock.option.layout.vertical': 'Vertical', 'engine.inspector.pageBlock.option.layout.horizontal': 'Horizontal', - 'engine.inspector.pageBlock.field.object-form.columns': 'Columns (grid layout)', + 'engine.inspector.pageBlock.field.object-form.columns': 'Field columns (1–4)', 'engine.inspector.pageBlock.field.object-form.fields': 'Fields', 'engine.inspector.pageBlock.field.object-form.title': 'Title', 'engine.inspector.pageBlock.field.object-form.description': 'Description', @@ -3670,7 +3670,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.pageBlock.field.object-form.layout': '布局', 'engine.inspector.pageBlock.option.layout.vertical': '纵向', 'engine.inspector.pageBlock.option.layout.horizontal': '横向', - 'engine.inspector.pageBlock.field.object-form.columns': '列数(网格布局)', + 'engine.inspector.pageBlock.field.object-form.columns': '字段列数(1–4)', 'engine.inspector.pageBlock.field.object-form.fields': '字段', 'engine.inspector.pageBlock.field.object-form.title': '标题', 'engine.inspector.pageBlock.field.object-form.description': '描述', diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts index aebe9886fd..9c89241a35 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts @@ -288,7 +288,7 @@ describe('en-US labels are unchanged by the key migration (#3913)', () => { 'engine.inspector.pageBlock.field.page:card.bordered': 'Bordered', // the same field NAME in another block, with different text — the case that // makes the key positional - 'engine.inspector.pageBlock.field.object-form.columns': 'Columns (grid layout)', + 'engine.inspector.pageBlock.field.object-form.columns': 'Field columns (1–4)', 'engine.inspector.pageBlock.field.element:definition-list.columns': 'Columns (1 or 2)', 'engine.inspector.pageBlock.field.grid.columns': 'Columns', // text / json / color / field-picker diff --git a/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx b/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx index f0fd5620e3..586af12824 100644 --- a/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx +++ b/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx @@ -56,6 +56,8 @@ import { ComponentPropsMap } from '@objectstack/spec/ui'; // the components graph. Module scope, not a `beforeAll`, per AGENTS.md 测试纪律. import '../action-group'; import '../action-menu'; +// The leaf the size row below compares a group member with. +import '../action-button'; /** The row every predicate below is evaluated against. */ const DATA = { status: 'draft' }; @@ -257,6 +259,30 @@ describe('action:group — the member shape of `actions` (the MEMBER-PIN directi expect(screen.getByRole('button', { name: 'SHOWN' }).className).toContain('h-9'); }); + it.each(['default', 'sm', 'lg', 'icon', 'md'] as const)( + 'a member at `size: %s` draws exactly as an `action:button` at that size (the `UIActionSchema.size` changeset\'s claim)', + (size) => { + // The variant is held equal (`default`, the leaf's own default; a + // member's is `outline`), so the class lists differ only if the SIZE is + // drawn differently. `md` is mapped to `default` on both. + mount({ type: 'action:group', actions: [member('alpha', { size, variant: 'default' })] }); + const drawnAsMember = screen.getByRole('button', { name: 'ALPHA' }).className.split(' ').sort(); + cleanup(); + mount({ type: 'action:button', name: 'alpha', label: 'ALPHA', size }); + const drawnAsLeaf = screen.getByRole('button', { name: 'ALPHA' }).className.split(' ').sort(); + expect(drawnAsMember).toEqual(drawnAsLeaf); + }, + ); + + it('CONTROL: with NO size the two differ — a member falls back to `sm`, a leaf to `default` — so the rows above can fail', () => { + mount({ type: 'action:group', actions: [member('alpha', { variant: 'default' })] }); + const drawnAsMember = screen.getByRole('button', { name: 'ALPHA' }).className.split(' ').sort(); + cleanup(); + mount({ type: 'action:button', name: 'alpha', label: 'ALPHA' }); + const drawnAsLeaf = screen.getByRole('button', { name: 'ALPHA' }).className.split(' ').sort(); + expect(drawnAsMember).not.toEqual(drawnAsLeaf); + }); + it('a click runs the member through the runner by its OWN `type`, carrying its own keys', async () => { mount({ type: 'action:group', diff --git a/packages/plugin-map/README.md b/packages/plugin-map/README.md index b2e6772a8a..4fa47ae1ab 100644 --- a/packages/plugin-map/README.md +++ b/packages/plugin-map/README.md @@ -148,7 +148,7 @@ required" refusal below rather than a map. | `descriptionField` | Field shown under the title in the marker popup. | | `zoom` | Zoom level. Declaring it opts this view out of the auto-fit (see below). | | `center` | `[latitude, longitude]` — a two-number **tuple**, latitude first. Declaring it opts this view out of the auto-fit. | -| `style` | MapLibre style URL/spec, replacing the default public demo style. | +| `style` | MapLibre style URL/spec, replacing the default public demo style. The node-level `mapStyle` is read before it, so it applies when `mapStyle` is absent. | **Nothing is guessed — an unbound map REFUSES.** A map with no coordinate binding renders @@ -169,6 +169,14 @@ that declared no binding — it refuses, and the fix is to declare the binding. A title field is still never guessed (objectui#5953): an unconfigured marker takes its title from the record-title precedence above. +## Node-level keys beside the block + +| Key | Description | +| --- | --- | +| `mapStyle` | MapLibre style URL or spec, replacing the demo tiles. Read before `map.style`, so it wins when both are written. Not the node's base `style`, which is an inline CSS record. | +| `enableClustering` | `true` groups nearby markers into numbered clusters. Absent, the map clusters only above 100 markers; `false` turns clustering off at any count. | +| `navigation` | What a marker click opens — the spec's `NavigationConfig`. `drawer`, `modal` and `popover` open the marker's record; `new_window` (or `openNewTab: true`, which outranks every mode except `none`) opens the record page in a new tab; `preventNavigation: true` opens nothing. An absent key, `none` and `split` open nothing (the map hands the split shell no main panel). `page`, and a block without `mode` (the spec's `page` default), open the record page of the map's `objectName` through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, or on a map that names no `objectName`, the click opens nothing. A parent view's click handler outranks the whole key. | + ## Initial camera There is no default zoom and no default centre. With records to show and no diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 2658a9b2e8..66ff4e8b3a 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -397,6 +397,13 @@ function getMapConfig(schema: MapConfigSource): ObjectMapConfig { // `BaseSchema.style` — inline CSS, a different key with a different meaning — // and is no longer consumed here at all (objectui#5017; see // `warnOnTopLevelStyleUrl`). + // + // PRECEDENCE: `mapStyle` first, then `map.style` — on EVERY return path, + // including the declared block's below (objectui#11168 slice 3, the seat's + // ruling A). The installed `@objectstack/spec` row says so in `mapStyle`'s own + // describe ("Read before `map.style`"), and this lane follows the spec. The + // block path used to hand `config.style` back first, so a map writing both + // drew `map.style`; no authored producer writes both. const style: string | undefined = schema.mapStyle || schema.map?.style; // 1. The declared configuration input: `{ name: 'map', type: 'object' }` at @@ -410,7 +417,7 @@ function getMapConfig(schema: MapConfigSource): ObjectMapConfig { console.warn(`[ObjectMap] Invalid map configuration:`, result.error.format()); } warnOnShadowedFlatMapKeys(schema); - return { ...config, style: config.style || style }; + return { ...config, style }; } // 2. The internal flat form — the ObjectView / ListView flatten product. diff --git a/packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx b/packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx new file mode 100644 index 0000000000..5aa7c0da1c --- /dev/null +++ b/packages/plugin-map/src/__tests__/objectMapInputs-11168.test.tsx @@ -0,0 +1,601 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11168 slice 3 — what `object-map` PUBLISHES, and what its renderer + * does with each member of each structured key. + * + * `@objectstack/spec` 17.5.0 gave `object-map` a `ComponentPropsMap` row, and + * the repo-wide parity guard + * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) now loads + * this plugin, so it judges the registration in both directions. Under + * objectui#11111 decision 3 = B every key is decided by its own measurement. + * The judgment found three spec keys the registration did not publish — + * `mapStyle`, `navigation` and `enableClustering` — and the renderer honours + * all three, so they are declared. It also owes a member pin for every + * structured key the block publishes; this file carries six of them (`map`, + * `data`, `staticData`, `filter`, `sort`, `navigation`). The seventh, + * `dataSource`, is `ObjectMap.elementDataSource.test.tsx`. + * + * Every behavioural row mounts the block the way a page does — through the + * REAL `SchemaRenderer` and this package's own registration — with MapLibre + * stubbed (no WebGL here): the stub records the props the map hands MapGL and + * renders each marker as a clickable element, so every row asserts what was + * plotted, what was queried, or what a click opened. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { + RelatedRecordActionsProvider, + SchemaRenderer, + SchemaRendererProvider, + type RelatedRecordActionsValue, +} from '@object-ui/react'; +import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; + +/** The props `ObjectMap` handed the MapGL component on its last render. */ +const mapGl = vi.hoisted(() => ({ props: null as Record | null })); + +vi.mock('react-map-gl/maplibre', () => ({ + default: (props: Record) => { + mapGl.props = props; + return
{props.children as React.ReactNode}
; + }, + Map: ({ children }: { children?: React.ReactNode }) =>
{children}
, + NavigationControl: () =>
, + // A marker is a clickable element here, so a click reaches the map's own + // `onClick` with the `originalEvent` it stops. + Marker: ({ children, onClick }: { children?: React.ReactNode; onClick?: (e: unknown) => void }) => ( +
onClick?.({ originalEvent: { stopPropagation() {} } })}> + {children} +
+ ), + Popup: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})); + +// Registers `object-map` through this package's own entry, at module scope. +import '../index'; + +const OBJECT = 'store'; +const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' }; +const DEMO_STYLE = 'https://demotiles.maplibre.org/style.json'; + +/** + * Three stores. `owner` is drawn by nothing on the map itself — only by the + * overlay that lists the clicked record's fields — so its text on screen means + * "the marker's record opened". + */ +const ROWS = [ + { id: 'm1', name: 'Alpha', lat: 40, lng: -74, status: 'open', owner: 'Ada Lovelace', note: 'Flagship' }, + { id: 'm2', name: 'Beta', lat: 41, lng: -75, status: 'closed', owner: 'Grace Hopper', note: 'Outlet' }, + { id: 'm3', name: 'Gamma', lat: 42, lng: -76, status: 'open', owner: 'Alan Turing', note: 'Kiosk' }, +]; +const OPENED_RECORD_TEXT = 'Ada Lovelace'; + +function makeDataSource() { + return { + find: vi.fn(async (_object: string, _query?: Record) => ({ + data: [{ id: 'q1', name: 'Queried', lat: 10, lng: 10 }], + total: 1, + })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: { name: { type: 'text' } } })), + }; +} + +const mount = ( + schema: Record, + ds = makeDataSource(), + hostProps: Record = {}, +) => { + render( + + + , + ); + return ds; +}; + +/** Wait for the map to leave its placeholder. */ +const mapDrawn = () => waitFor(() => expect(screen.getByLabelText('Map')).toBeInTheDocument()); + +/** The single-record markers plotted (a cluster renders its own element). */ +const pins = () => screen.queryAllByTestId('map-marker').filter((marker) => marker.textContent === '📍'); +const clusters = () => screen.queryAllByTestId('map-cluster'); + +/** Give a click (or a query) every chance to have happened before asserting it did not. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 30)); + +/** The diagnostics the page validator raises, over the manifest the registry publishes. */ +const diagnose = (node: Record) => + validateTree( + node as never, + manifestFromConfigs( + ComponentRegistry.getKnownTypes().map((type) => { + const meta = ComponentRegistry.getMeta(type); + return { type, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; + }) as unknown as Parameters[0], + ), + ).diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.message]); + +const inputOf = (name: string) => + ((ComponentRegistry.getConfig('object-map', 'plugin-map') as { inputs?: Array> } | undefined) + ?.inputs ?? []).find((input) => input.name === name); + +const specRow = (ComponentPropsMap as unknown as Record { success: boolean } }>)[ + 'object-map' +]; + +beforeEach(() => { + mapGl.props = null; + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); + try { window.localStorage.clear(); } catch { /* private mode */ } +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +// ── The registration ──────────────────────────────────────────────────────── + +describe('object-map publishes the three spec keys its renderer honours (objectui#11168)', () => { + it('declares `mapStyle`, `navigation` and `enableClustering` with the spec row\'s kinds', () => { + expect(inputOf('mapStyle')?.type).toBe('string'); + expect(inputOf('navigation')?.type).toBe('object'); + expect(inputOf('enableClustering')?.type).toBe('boolean'); + }); + + it('the page validator accepts all three, and a bogus key is still reported (the control)', () => { + // Before this slice each of the three was an `unknown-prop` warning. + expect( + diagnose({ + type: 'object-map', + objectName: OBJECT, + map: MAP, + mapStyle: 'https://tiles.example.com/style.json', + navigation: { mode: 'drawer' }, + enableClustering: true, + }), + ).toEqual([]); + expect(diagnose({ type: 'object-map', objectName: OBJECT, map: MAP, bogusProp: 1 })).toEqual([ + ['unknown-prop', ' has no prop "bogusProp"'], + ]); + }); + + it('the installed spec row accepts every shape the rows below author', () => { + expect( + specRow.safeParse({ + objectName: OBJECT, + map: { ...MAP, descriptionField: 'note', zoom: 5, center: [40, -74], style: 'https://b.example/s.json' }, + mapStyle: 'https://a.example/s.json', + navigation: { mode: 'drawer', size: 'lg' }, + enableClustering: true, + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + sort: [{ field: 'name', order: 'desc' }], + }).success, + ).toBe(true); + expect(specRow.safeParse({ staticData: ROWS }).success).toBe(true); + expect(specRow.safeParse({ data: { provider: 'value', items: ROWS } }).success).toBe(true); + // CONTROL: the row is not accepting everything. + expect(specRow.safeParse({ objectName: OBJECT, bogusProp: 1 }).success).toBe(false); + }); +}); + +describe('`object-map.mapStyle` is read BEFORE `map.style` — the spec row\'s own describe (objectui#11168, ruling A)', () => { + const A = 'https://a.example/style.json'; + const B = 'https://b.example/style.json'; + + it('the registration publishes the installed spec row\'s describe, verbatim', () => { + // Read through `_def` for the reason `ObjectMap.dataArmSpecRow-8348` gives: + // the row is a lazy schema, and `_def` is the internal the spec publishes no type for. + type Member = { description?: string }; + const def = (ComponentPropsMap as unknown as Record | (() => Record) } }>)['object-map']._def; + const shape = typeof def.shape === 'function' ? def.shape() : def.shape; + const describe = shape.mapStyle?.description; + expect(describe).toContain('Read before `map.style`'); + expect(inputOf('mapStyle')?.description).toBe(describe); + }); + + it.each([ + ['both written: `mapStyle` draws', { mapStyle: A, map: { ...MAP, style: B } }, A], + ['only `map.style`: it draws', { map: { ...MAP, style: B } }, B], + ['only `mapStyle`: it draws', { mapStyle: A, map: MAP }, A], + ['neither: the public demo tiles (the control)', { map: MAP }, DEMO_STYLE], + ])('%s', async (_label, schema, drawn) => { + mount({ ...schema, staticData: ROWS }); + await mapDrawn(); + expect(mapGl.props?.mapStyle).toBe(drawn); + }); +}); + +describe('`enableClustering` — as described (objectui#11168)', () => { + it('`enableClustering: true` clusters nearby markers; absent, a map of a few markers draws each one', async () => { + const near = [ROWS[0], { ...ROWS[1], lat: 40.001, lng: -74.001 }]; + mount({ map: MAP, staticData: near, enableClustering: true }); + await mapDrawn(); + expect(clusters()).toHaveLength(1); + expect(pins()).toHaveLength(0); + cleanup(); + mount({ map: MAP, staticData: near }); + await mapDrawn(); + expect(clusters()).toHaveLength(0); + expect(pins()).toHaveLength(2); + }); + + it('absent, the map clusters above 100 markers — and `enableClustering: false` turns that off', async () => { + const many = Array.from({ length: 120 }, (_, i) => ({ id: `x${i}`, name: `N${i}`, lat: 40 + i * 0.0001, lng: -74 })); + mount({ map: MAP, staticData: many }); + await mapDrawn(); + expect(clusters().length).toBeGreaterThan(0); + cleanup(); + mount({ map: MAP, staticData: many, enableClustering: false }); + await mapDrawn(); + expect(clusters()).toHaveLength(0); + expect(pins()).toHaveLength(120); + }); +}); + +// ── map ───────────────────────────────────────────────────────────────────── + +describe('`object-map.map` — the members are field names and the camera (objectui#11168)', () => { + it('`latitudeField` / `longitudeField` place one marker per record; fields that hold no number place none', async () => { + mount({ map: MAP, staticData: ROWS }); + await mapDrawn(); + expect(pins()).toHaveLength(3); + cleanup(); + // CONTROL: the same rows under a binding that names no coordinate field. + mount({ map: { ...MAP, latitudeField: 'status' }, staticData: ROWS }); + await mapDrawn(); + expect(pins()).toHaveLength(0); + }); + + it.each([ + ['a "lat,lng" string', '40,-74'], + ['a { lat, lng } object', { lat: 40, lng: -74 }], + ['a [lat, lng] pair', [40, -74]], + ])('`locationField` reads %s', async (_label, location) => { + mount({ map: { locationField: 'where', titleField: 'name' }, staticData: [{ id: 'l1', name: 'Loc', where: location }] }); + await mapDrawn(); + expect(pins()).toHaveLength(1); + }); + + it('`titleField` and `descriptionField` name the record fields the marker popup shows', async () => { + mount({ map: { ...MAP, descriptionField: 'note' }, staticData: [ROWS[0]] }); + await mapDrawn(); + fireEvent.click(pins()[0]); + const popup = await screen.findByTestId('map-popup'); + expect(popup.querySelector('h3')?.textContent).toBe('Alpha'); + expect(popup).toHaveTextContent('Flagship'); + cleanup(); + mount({ map: { ...MAP, titleField: 'status' }, staticData: [ROWS[0]] }); + await mapDrawn(); + fireEvent.click(pins()[0]); + expect((await screen.findByTestId('map-popup')).querySelector('h3')?.textContent).toBe('open'); + }); + + it('`zoom` and `center` (`[latitude, longitude]`) are the initial camera; absent, the camera fits the markers', async () => { + mount({ map: { ...MAP, zoom: 5, center: [48.85, 2.35] }, staticData: ROWS }); + await mapDrawn(); + expect(mapGl.props?.initialViewState).toEqual({ latitude: 48.85, longitude: 2.35, zoom: 5 }); + cleanup(); + mount({ map: MAP, staticData: ROWS }); + await mapDrawn(); + expect(mapGl.props?.initialViewState).toHaveProperty('bounds'); + }); +}); + +// ── data / staticData ─────────────────────────────────────────────────────── + +describe('`object-map.data` — the members are the `{ provider, … }` configurations (objectui#11168)', () => { + it('`{ provider: "value", items }` plots those records and queries nothing', async () => { + const ds = mount({ map: MAP, data: { provider: 'value', items: ROWS } }); + await mapDrawn(); + expect(pins()).toHaveLength(3); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('`{ provider: "object", object }` queries THAT object and plots what it returns', async () => { + const ds = mount({ map: MAP, data: { provider: 'object', object: OBJECT } }); + await waitFor(() => expect(ds.find).toHaveBeenCalledWith(OBJECT, expect.anything())); + await waitFor(() => expect(pins()).toHaveLength(1)); + }); + + it('`{ provider: "api" }` plots nothing and queries nothing — the map reads no request', async () => { + const ds = mount({ map: MAP, data: { provider: 'api', read: { url: '/api/stores' } } }); + await settle(); + expect(pins()).toHaveLength(0); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('it is read FIRST: a `data` configuration wins over `staticData` and over `objectName`', async () => { + const ds = mount({ + map: MAP, + objectName: OBJECT, + staticData: [ROWS[0]], + data: { provider: 'value', items: ROWS }, + }); + await mapDrawn(); + expect(pins()).toHaveLength(3); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); + +describe('`object-map.staticData` — the members are RECORDS (objectui#11168)', () => { + it('each record is plotted by the `map` block\'s fields, and one without coordinates is skipped, not invented', async () => { + mount({ map: MAP, staticData: [...ROWS, { id: 'm4', name: 'Nowhere' }] }); + await mapDrawn(); + expect(pins()).toHaveLength(3); + }); + + it('it is read SECOND, above `objectName`: a map carrying both plots these rows and never queries the object', async () => { + const ds = mount({ map: MAP, objectName: OBJECT, staticData: ROWS }); + await mapDrawn(); + await settle(); + expect(pins()).toHaveLength(3); + expect(ds.find).not.toHaveBeenCalled(); + cleanup(); + // CONTROL: without the inline rows the same node queries the object. + const live = mount({ map: MAP, objectName: OBJECT }); + await waitFor(() => expect(live.find).toHaveBeenCalledWith(OBJECT, expect.anything())); + }); +}); + +// ── filter / sort ─────────────────────────────────────────────────────────── + +describe('`object-map.filter` — the members are `{ field, operator, value }` rules (objectui#11168)', () => { + const OPEN = [{ field: 'status', operator: 'equals', value: 'open' }]; + + it('a rule narrows the inline records plotted, and `operator` is read', async () => { + mount({ map: MAP, staticData: ROWS, filter: OPEN }); + await mapDrawn(); + expect(pins()).toHaveLength(2); + cleanup(); + mount({ map: MAP, staticData: ROWS, filter: [{ ...OPEN[0], operator: 'not_equals' }] }); + await mapDrawn(); + expect(pins()).toHaveLength(1); + }); + + it('on the object query the rule reaches `$filter` with its members unchanged; with no `filter`, no `$filter`', async () => { + const ds = mount({ map: MAP, objectName: OBJECT, filter: OPEN }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(ds.find.mock.calls[0][1]?.$filter).toEqual(OPEN); + cleanup(); + const bare = mount({ map: MAP, objectName: OBJECT }); + await waitFor(() => expect(bare.find).toHaveBeenCalled()); + expect(bare.find.mock.calls[0][1]?.$filter).toBeUndefined(); + }); +}); + +describe('`object-map.sort` — the members are `{ field, order }` (objectui#11168)', () => { + it('each member lowers to `field -> direction` on `$orderby`, in authored order, an omitted `order` reading ascending', async () => { + const ds = mount({ + map: MAP, + objectName: OBJECT, + sort: [{ field: 'status', order: 'desc' }, { field: 'name' }], + }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + const orderby = ds.find.mock.calls[0][1]?.$orderby as Record; + expect(orderby).toEqual({ status: 'desc', name: 'asc' }); + expect(Object.keys(orderby)).toEqual(['status', 'name']); + }); + + it('CONTROL: with no `sort` the query carries no ordering', async () => { + const ds = mount({ map: MAP, objectName: OBJECT }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + const orderby = ds.find.mock.calls[0][1]?.$orderby as Record | undefined; + expect(orderby === undefined || Object.keys(orderby).length === 0).toBe(true); + }); +}); + +// ── navigation ────────────────────────────────────────────────────────────── + +/** Mount one map with an authored `navigation` (or none), click its one marker, and hand back the `window.open` spy. */ +async function clickMarker(navigation: Record | undefined, hostProps: Record = {}) { + const open = vi.fn(); + vi.stubGlobal('open', open); + mount({ map: MAP, objectName: OBJECT, staticData: [ROWS[0]], ...(navigation ? { navigation } : {}) }, makeDataSource(), hostProps); + await mapDrawn(); + fireEvent.click(pins()[0]); + return { open }; +} + +const dialog = () => document.querySelector('[role="dialog"]') as HTMLElement | null; +const openedRecord = () => screen.queryByText(OPENED_RECORD_TEXT); + +async function expectNothingOpened(open: ReturnType, why: string) { + await settle(); + expect(dialog(), `${why}: an overlay opened`).toBeNull(); + expect(openedRecord(), `${why}: the record was drawn`).toBeNull(); + expect(open, `${why}: a tab was opened`).not.toHaveBeenCalled(); +} + +/** The resolved overlay width, read off the `--ov-w` custom property the shared shell publishes. */ +function panelWidth(): string { + const panel = dialog(); + expect(panel, 'overlay panel').not.toBeNull(); + return panel!.style.getPropertyValue('--ov-w').trim(); +} + +describe('`object-map.navigation` — the members decide what a marker click opens (objectui#11168)', () => { + it('LIT CONTROL: `mode: "drawer"` opens the marker\'s record in a drawer', async () => { + const { open } = await clickMarker({ mode: 'drawer' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(openedRecord()).not.toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it.each(['modal', 'popover'] as const)('`mode: "%s"` opens the marker\'s record in that overlay', async (mode) => { + const { open } = await clickMarker({ mode }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(openedRecord()).not.toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it('`mode: "split"` opens NOTHING — the map hands the split shell no main panel', async () => { + const { open } = await clickMarker({ mode: 'split' }); + await expectNothingOpened(open, 'split'); + }); + + it('`mode: "none"` and `preventNavigation: true` open nothing, the flag outranking an overlay mode', async () => { + const none = await clickMarker({ mode: 'none' }); + await expectNothingOpened(none.open, 'none'); + cleanup(); + const prevented = await clickMarker({ mode: 'drawer', preventNavigation: true }); + await expectNothingOpened(prevented.open, 'preventNavigation'); + }); + + it('`mode: "new_window"` opens the record page in a new tab, and `openNewTab` outranks an overlay mode but not `none`', async () => { + const tab = await clickMarker({ mode: 'new_window' }); + await waitFor(() => expect(tab.open).toHaveBeenCalledWith(`/${OBJECT}/record/m1`, '_blank')); + expect(dialog()).toBeNull(); + cleanup(); + const forced = await clickMarker({ mode: 'drawer', openNewTab: true }); + await waitFor(() => expect(forced.open).toHaveBeenCalledTimes(1)); + expect(dialog()).toBeNull(); + cleanup(); + const none = await clickMarker({ mode: 'none', openNewTab: true }); + await expectNothingOpened(none.open, 'none beside openNewTab'); + }); + + it('`size` and `width` are one width decision: `width` wins, a bucket resolves, `auto` lands on the default', async () => { + await clickMarker({ mode: 'drawer' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + const unsized = panelWidth(); + cleanup(); + await clickMarker({ mode: 'drawer', size: 'lg' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(panelWidth()).not.toBe(unsized); + cleanup(); + await clickMarker({ mode: 'drawer', size: 'sm', width: '720px' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(panelWidth()).toContain('720px'); + cleanup(); + await clickMarker({ mode: 'drawer', size: 'auto' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(panelWidth()).toBe(unsized); + }); + + it('a parent view\'s click handler OUTRANKS the whole key, overlay mode included', async () => { + const onRowClick = vi.fn(); + const { open } = await clickMarker({ mode: 'drawer' }, { onRowClick }); + await waitFor(() => expect(onRowClick).toHaveBeenCalledTimes(1)); + expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: 'm1' }); + await expectNothingOpened(open, 'parent handler'); + }); + + it('with NO host navigator, the key ABSENT, `page`, or a block without `mode` opens nothing — there is no record page to open', async () => { + const absent = await clickMarker(undefined); + await expectNothingOpened(absent.open, 'absent key'); + cleanup(); + const page = await clickMarker({ mode: 'page' }); + await expectNothingOpened(page.open, 'page'); + cleanup(); + const modeless = await clickMarker({ size: 'lg' }); + await expectNothingOpened(modeless.open, 'mode-less block'); + }); +}); + +/** + * A host that publishes its record navigator the way the console does on its + * custom pages, record pages and list views (`RelatedRecordActionsContext`): + * `openRecord` is the spy a `page` click must reach. The same host the + * board's and the calendar's `NavigationMembers-8652` pins mount. + */ +function recordNavigatorHost() { + const openRecord = vi.fn(); + const value: RelatedRecordActionsValue = { + resolve: () => ({}), + recordHref: (objectName, recordId) => `/apps/demo/${objectName}/record/${recordId}`, + openRecord, + }; + return { value, openRecord }; +} + +/** `clickMarker`, with the map mounted through the real `SchemaRenderer` UNDER that host. */ +async function clickMarkerUnderHost( + navigation: Record | undefined, + host: RelatedRecordActionsValue, + { namesObject = true } = {}, +) { + const open = vi.fn(); + vi.stubGlobal('open', open); + render( + + + + + , + ); + await mapDrawn(); + fireEvent.click(pins()[0]); + return { open }; +} + +describe('`object-map.navigation` `page` under the host\'s record navigator (objectui#11168, objectui#11293)', () => { + it('LIT CONTROL: under the same host, `mode: "drawer"` opens the drawer and does not navigate', async () => { + // First, because the rows below would be vacuous against a map whose + // markers are not clickable under this host. + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickMarkerUnderHost({ mode: 'drawer' }, value); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(openRecord).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + }); + + it('`mode: "page"` opens the record page of the map\'s object through the host, and no overlay', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickMarkerUnderHost({ mode: 'page' }, value); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, 'm1'); + expect(dialog()).toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it('a block WITHOUT `mode` resolves to `page`, and navigates the same way', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickMarkerUnderHost({ size: 'lg' }, value); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, 'm1'); + expect(dialog()).toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it.each([ + ['the key ABSENT', undefined], + ['`mode: "none"`', { mode: 'none' }], + ['`mode: "split"`', { mode: 'split' }], + ] as const)('%s still opens nothing under the host, and does not navigate', async (why, navigation) => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickMarkerUnderHost(navigation, value); + await expectNothingOpened(open, String(why)); + expect(openRecord).not.toHaveBeenCalled(); + }); + + it('a map that names no `objectName` (inline rows alone) has no record page to open, even under the host', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickMarkerUnderHost({ mode: 'page' }, value, { namesObject: false }); + await expectNothingOpened(open, 'page without objectName'); + expect(openRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-map/src/index.tsx b/packages/plugin-map/src/index.tsx index 1dfacc4096..fe787831e5 100644 --- a/packages/plugin-map/src/index.tsx +++ b/packages/plugin-map/src/index.tsx @@ -88,6 +88,21 @@ export const ObjectMapRenderer: React.FC = elementDataSourceBlock(({ schema // `type: 'array'` makes the html tier refuse it too. Pinned in // `__tests__/queryKeysDeclared-8220.test.tsx`. // +// `mapStyle`, `navigation` and `enableClustering` are declared (objectui#11168 +// slice 3, objectui#11111 decision 3 = B): `@objectstack/spec` 17.5.0's +// `object-map` row declares all three, the repo-wide registry parity guard now +// loads this plugin and judges the registration, and each was measured honoured +// through the real `SchemaRenderer` first — so without an input the html tier +// reported a working key as `unknown-prop`. Each description states what the +// renderer was measured to do. `mapStyle`'s is the spec row's own describe, +// verbatim: the renderer used to read a `style` inside the `map` block first, +// against that describe ("Read before `map.style`"), and the seat ruled the +// renderer follows the spec (option A) — `getMapConfig` now reads `mapStyle` +// first on every path, pinned both ways. The members +// of every structured key here are pinned in +// `__tests__/objectMapInputs-11168.test.tsx`; `dataSource`'s in +// `ObjectMap.elementDataSource.test.tsx`. +// // The list is spelled INLINE rather than spread from a shared constant: // `check:component-surface-parity` cannot name the entries of a spread, so a // spread list would drop this registration out of that reader's population. @@ -98,11 +113,14 @@ ComponentRegistry.register('object-map', ObjectMapRenderer, { category: 'view', inputs: [ { name: 'objectName', type: 'string', description: 'ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`, or the node’s `dataSource` binding; the `object-map` schema refuses a block that declares none of them.' }, - { name: 'map', type: 'object', description: 'latitudeField, longitudeField, titleField' }, + { name: 'map', type: 'object', description: 'The field configuration: `latitudeField` / `longitudeField` (or one `locationField` holding a `"lat,lng"` string, a `{ lat, lng }` object or a `[lat, lng]` pair) place each record’s marker, and a record with no readable coordinates is not plotted; `titleField` and `descriptionField` name what the marker popup shows; `zoom` and `center` (`[latitude, longitude]`) set the initial camera, which otherwise fits the markers; `style` is a MapLibre style URL, read after the node’s `mapStyle`.' }, { name: 'data', type: 'object', description: 'A `{ provider, … }` data-source configuration, read FIRST on the record-source ladder: a map carrying one never reaches `staticData` and never queries `objectName`. `{ provider: \'value\', items }` plots those rows and `{ provider: \'object\', object }` queries that object, both narrowed by `filter` and ordered by `sort`. The `api` provider is not implemented on the map and plots no markers. A bare array is not this key’s shape and is not a record source: the map falls through to `staticData`, then `objectName`, so inline rows belong under `staticData`.' }, { name: 'staticData', type: 'array', description: 'Inline records, read SECOND on the record-source ladder: a `data` configuration wins and this key is then never reached, while `objectName` is read AFTER it, so a map carrying both plots these rows and never queries that object. `filter` and `sort` narrow and order these rows exactly as they do fetched ones.' }, { name: 'filter', type: 'array', description: 'Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the markers the map plots — fetched rows and inline (`staticData`, `{ provider: \'value\' }`) rows alike. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. The MongoDB-style record form is not this key’s shape.' }, { name: 'sort', type: 'array', description: 'Marker order in `[{ field, order }]` form, ordering the rows the map plots. Lowered to `$orderby` on the same query.' }, + { name: 'mapStyle', type: 'string', description: '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' }, + { name: 'navigation', type: 'object', description: 'What a marker click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens nothing: this renderer supplies no drawer default. `mode` is an overlay (`drawer`, `modal`, `split`, `popover`), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `page` default. `drawer`, `modal` and `popover` open the marker’s record in that overlay, but `split` opens nothing, because the map hands the split shell no main panel; `new_window` opens the record page in a new tab; `none` opens nothing. `page` opens the record page of the map’s `objectName` through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, or on a map that names no `objectName`, there is no record page to open and the click opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.' }, + { name: 'enableClustering', type: 'boolean', description: 'Group nearby markers into one numbered cluster. Absent, the map clusters only above 100 markers; `false` turns clustering off at any count.' }, ], }); diff --git a/packages/plugin-timeline/README.md b/packages/plugin-timeline/README.md index 11176a59d4..5d51313094 100644 --- a/packages/plugin-timeline/README.md +++ b/packages/plugin-timeline/README.md @@ -99,12 +99,14 @@ reference, `ViewNavigationConfig` in `@object-ui/types`: `mode` (`page`, `openNewTab` and `preventNavigation`. `drawer`, `modal` and `popover` open the entry's record in that overlay, `new_window` and `openNewTab: true` open the record page in a new tab, `preventNavigation: true` opens nothing, and `size` -sets the overlay width. A click handler from a parent view outranks the whole -key. ⚠️ Write the block with `mode`: with the key absent, or with a block -written without `mode` (it takes the spec's `page` default), a click opens -nothing on a timeline no parent view navigates for (objectui#11293). `split` -opens nothing on this block either, because the timeline gives the split shell -no main panel. +sets the overlay width. `page`, and a block written without `mode` (it takes +the spec's `page` default), open the record page through the record navigator +the host publishes (the console publishes one on its custom pages, record pages +and list views); under a host that publishes none, such as an embedded renderer, +the click opens nothing. A click handler from a parent view outranks the whole +key. ⚠️ With the key absent a click opens nothing: this renderer supplies no +drawer default. `split` opens nothing on this block either, because the +timeline gives the split shell no main panel. ## Links diff --git a/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx b/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx index ecd991d4f3..836c307a97 100644 --- a/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx +++ b/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx @@ -44,12 +44,20 @@ * ⚠️ And the values the timeline does NOT honour on its own, pinned because * the registration's description states them and a description is a claim * about this renderer. Unlike the board and the calendar, this renderer - * supplies no drawer default, so an ABSENT key opens nothing; `page`, and a - * block written without `mode` (the spec's `page` default), open nothing on a - * timeline no parent view navigates for (objectui#11293); and `split` opens - * nothing, because the timeline hands the split shell no main panel. A - * timeline that learns any of these reddens its row, and the fix is to rewrite - * that description with it, ⛔ not to relax the row. + * supplies no drawer default, so an ABSENT key opens nothing, with a host + * record navigator mounted or not; and `split` opens nothing, because the + * timeline hands the split shell no main panel. A timeline that learns any of + * these reddens its row, and the fix is to rewrite that description with it, + * ⛔ not to relax the row. + * + * And `page`, pinned both ways. Since objectui#11293 `useNavigationOverlay` + * hands a `page` click it has no `onNavigate` for to the record navigator the + * HOST publishes (`RelatedRecordActionsContext.openRecord`), which the console + * mounts on its custom pages, record pages and list views. So under such a + * host `page`, and a block written without `mode` (the spec's `page` default), + * open the record page; with no host navigator there is no record page to + * open, and the click opens nothing (objectui#11168 slice 3 round 4, the + * board's `kanbanNavigationMembers-8652` host). */ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -59,6 +67,7 @@ import { assertNoOtherNetworkEscape, installRecordSecurityExplainDouble, } from '@object-ui/test-support'; +import { RelatedRecordActionsProvider, type RelatedRecordActionsValue } from '@object-ui/react'; import { ObjectTimeline, type ObjectTimelineProps } from '../ObjectTimeline'; @@ -83,6 +92,7 @@ type Navigation = ObjectTimelineProps['schema']['navigation']; async function clickEntry( navigation: Navigation, props: Pick = {}, + host?: RelatedRecordActionsValue, ) { const open = vi.fn(); vi.stubGlobal('open', open); @@ -95,7 +105,8 @@ async function clickEntry( // `data` is read off the PROPS (pre-fetched records), so the timeline issues // no query and the click is the only thing under test. const extra = { data: RECORDS } as Record; - render(); + const timeline = ; + render(host ? {timeline} : timeline); fireEvent.click(await screen.findByText('Kickoff')); return { open }; } @@ -230,15 +241,14 @@ describe('the registration description\'s warnings are TRUE (objectui#8654)', () await expectNothingOpened(open, 'absent key — rewrite the `navigation` description'); }); - it('`mode: "page"` opens NOTHING on a timeline no parent view navigates for', async () => { + it('with NO host navigator `mode: "page"` has no record page to open, and opens NOTHING', async () => { const { open } = await clickEntry({ mode: 'page' }); await expectNothingOpened(open, 'page — rewrite the `navigation` description'); }); - it('a block WITHOUT `mode` resolves to `page` — so it opens nothing either', async () => { - // The trap the description warns about: `size` alone, written to widen an - // overlay, silences the click, because the absent `mode` is the spec's - // `page`. Compare the LIT CONTROL. + it('with NO host navigator a block WITHOUT `mode` resolves to `page` — so it opens nothing either', async () => { + // `size` alone, written to widen an overlay, takes the spec's `page` + // default rather than a drawer. Compare the LIT CONTROL. const { open } = await clickEntry({ size: 'lg' }); await expectNothingOpened(open, 'mode-less block — rewrite the `navigation` description'); }); @@ -248,3 +258,68 @@ describe('the registration description\'s warnings are TRUE (objectui#8654)', () await expectNothingOpened(open, 'split — rewrite the `navigation` description'); }); }); + +/** + * A host that publishes its record navigator the way the console does + * (`RelatedRecordActionsContext`): `openRecord` is the spy a `page` click must + * reach. `resolve` returns no handlers, as the console's list surface does. + */ +function recordNavigatorHost() { + const openRecord = vi.fn(); + const value: RelatedRecordActionsValue = { + resolve: () => ({}), + recordHref: (objectName, recordId) => `/apps/demo/${objectName}/record/${recordId}`, + openRecord, + }; + return { value, openRecord }; +} + +describe('`page` opens the record page through the host\'s record navigator (objectui#11293)', () => { + it('LIT CONTROL: under the same host, `mode: "drawer"` opens the drawer and does not navigate', async () => { + // First, because the rows below would be vacuous against a timeline whose + // entries are not clickable under this host. + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickEntry({ mode: 'drawer' }, {}, value); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(openRecord).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + }); + + it('`mode: "page"` opens the record page through the host, and no overlay', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickEntry({ mode: 'page' }, {}, value); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, '1'); + expect(dialog()).toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it('a block WITHOUT `mode` resolves to `page`, and navigates the same way', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickEntry({ size: 'lg' }, {}, value); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, '1'); + expect(dialog()).toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it('a parent view\'s `onRowClick` still outranks the host navigator on `page`', async () => { + const { value, openRecord } = recordNavigatorHost(); + const onRowClick = vi.fn(); + await clickEntry({ mode: 'page' }, { onRowClick }, value); + await waitFor(() => expect(onRowClick).toHaveBeenCalledTimes(1)); + await settle(); + expect(openRecord).not.toHaveBeenCalled(); + }); + + it.each([ + ['the key ABSENT', undefined], + ['`mode: "none"`', { mode: 'none' }], + ['`mode: "split"`', { mode: 'split' }], + ] as const)('%s still opens nothing under the host, and does not navigate', async (why, navigation) => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickEntry(navigation, {}, value); + await expectNothingOpened(open, String(why)); + expect(openRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-timeline/src/index.tsx b/packages/plugin-timeline/src/index.tsx index 087af17934..2a898b3e75 100644 --- a/packages/plugin-timeline/src/index.tsx +++ b/packages/plugin-timeline/src/index.tsx @@ -379,11 +379,12 @@ const TIMELINE_SORT_DESCRIPTION = 'Entry order in `[{ field, order }]` form, ord // on the real component, and it differs from the board's and the calendar's in // two places: this renderer supplies no drawer default, so an ABSENT key opens // nothing, and it hands the split shell no main panel, so `split` opens nothing -// either. `page`, and a block without `mode`, open nothing as on the siblings -// (objectui#11293). Members pinned in +// either. `page`, and a block without `mode`, open the record page through the +// host's record navigator as on the siblings, and nothing under a host that +// publishes none (objectui#11293). Members pinned in // `__tests__/timelineNavigationMembers-8654.test.tsx`; a renderer that changes // any of these reddens those rows, and the fix is to rewrite this description. -const TIMELINE_NAVIGATION_DESCRIPTION = 'What an entry click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. Write it with `mode`: with the key ABSENT, or with a block that omits `mode` (it takes the spec’s `page` default), a click opens nothing on a timeline no parent view navigates for. `drawer`, `modal` and `popover` open the entry’s record in that overlay; `new_window` opens the record page in a new tab; `page`, `none` and `split` open nothing here, because the timeline hands the split shell no main panel. `openNewTab: true` opens the record page in a new tab and outranks the mode, `preventNavigation: true` opens nothing, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.'; +const TIMELINE_NAVIGATION_DESCRIPTION = 'What an entry click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens nothing: this renderer supplies no drawer default. `mode` is an overlay (`drawer`, `modal`, `split`, `popover`), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `page` default. `drawer`, `modal` and `popover` open the entry’s record in that overlay, but `split` opens nothing, because the timeline hands the split shell no main panel; `new_window` opens the record page in a new tab; `none` opens nothing. `page` opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, there is no record page to open and the click opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.'; ComponentRegistry.register('object-timeline', ObjectTimelineRenderer, { namespace: 'plugin-timeline', diff --git a/packages/plugin-tree/README.md b/packages/plugin-tree/README.md index 2bd6551ddf..a6e8b10e7e 100644 --- a/packages/plugin-tree/README.md +++ b/packages/plugin-tree/README.md @@ -14,9 +14,9 @@ It registers two component types via the `ComponentRegistry`: ## Usage -Author an `object-tree` node. Its config keys sit **flat on the node**, and -`ObjectQLComponentSchema` narrows on `type`, so each one is checked against -`ObjectTreeSchema`: +Author an `object-tree` node. `ObjectQLComponentSchema` narrows on `type`, so +each key is checked against `ObjectTreeSchema`, which mirrors the `object-tree` +row of `@objectstack/spec` (17.5.0 and later): ```ts import type { ObjectQLComponentSchema } from '@object-ui/types'; @@ -24,25 +24,73 @@ import type { ObjectQLComponentSchema } from '@object-ui/types'; const schema: ObjectQLComponentSchema = { type: 'object-tree', objectName: 'business_unit', - parentField: 'parent', // single-parent pointer (auto-detected if omitted) - labelField: 'name', // indented first column - fields: ['name', 'manager'], // additional flat columns - defaultExpandedDepth: 1, // 0 = roots only; omit = expand all + tree: { + parentField: 'parent', // single-parent pointer (auto-detected if omitted) + labelField: 'name', // indented first column + fields: ['manager'], // additional flat columns + defaultExpandedDepth: 1, // 0 = roots only; omit = expand all + }, + filter: [{ field: 'status', operator: 'equals', value: 'active' }], + navigation: { mode: 'drawer' }, }; ``` -### Config +### Keys + +| Key | Description | +| --- | --- | +| `objectName` | The object to query. One of `data`, `staticData` and `objectName` is the record source, read in that order; `objectui validate` refuses a node with none of the three. | +| `data` | A `{ provider, … }` configuration, read first. `{ provider: 'value', items }` draws those records and `{ provider: 'object', object }` queries that object. The `api` and `schema` providers draw no rows on the tree. A bare array is not a record source (objectui#8348): inline rows go under `staticData`. | +| `staticData` | Inline records, read second: a `data` configuration wins, and a tree carrying both `staticData` and `objectName` draws these rows and never queries the object. | +| `tree` | The field configuration — the spec's `TreeConfig` (table below). | +| `filter` | Rule array `[{ field, operator, value }]`, lowered to `$filter` and applied to inline rows as well. Context tokens such as `{current_user_id}` are resolved first. A record whose parent the filter removed is drawn as a root. | +| `navigation` | What a row click opens (below). | + +The node's `dataSource` binding is **not** a record source on this block: the +registration is not wrapped in `ElementDataSourceGate`, so nothing lands a +binding's object on `objectName`, and `objectui validate` does not count it. + +### The `tree` block | Key | Default | Description | | --- | --- | --- | | `parentField` | auto-detected | Field holding the parent reference. When omitted, the renderer picks the object's `tree` field (or a lookup/master_detail that references the same object). | | `labelField` | `name` | Field rendered indented in the first column. | -| `fields` | `[]` | Additional fields rendered as flat columns. | +| `fields` | `[]` | Additional fields rendered as flat columns after the label. | | `defaultExpandedDepth` | _unset_ | Initial expansion depth. `0` = roots only; unset = expand everything. | Records whose parent is missing (or points outside the result set) are kept as roots, so nothing is silently dropped. +⚠️ The same four keys written **flat** on the node are still read, and a flat +key outranks the block's member of the same name. They are the form a host +composes; the spec row declares only the `tree` block, so author that. + +### `navigation` + +`navigation` takes the spec's `NavigationConfig` (`{ mode, size, width, +openNewTab, preventNavigation }`), the block a list view declares. On a tree no +parent view navigates for: + +- **Absent**: a click opens nothing. This renderer supplies no drawer default. +- `drawer`, `modal` and `popover` open the row's record in that overlay; `split` + opens it beside the tree, which stays drawn. +- `new_window` opens `/{objectName}/record/{id}` in a new tab; `none` opens + nothing. +- `page`, and a block without `mode` (it takes the spec's `page` default), open + a record page through the record navigator the host publishes (the console + publishes one on its custom pages, record pages and list views). The object + is `data.object` when `data` is the object provider, so it wins when both + are written, and the tree's `objectName` otherwise. Under a host that + publishes none, such as an embedded renderer, or on a tree that names neither + (inline rows with no `objectName`), there is no record page to open and the + click opens nothing. +- `preventNavigation: true` opens nothing whatever the mode. `openNewTab: true` + opens the record page in a new tab and outranks every mode except `none`. +- `size` picks the overlay width bucket; the deprecated `width` wins over it. + +A click handler from a parent view outranks the whole key. + ### Expansion: the seed and the user's answer `defaultExpandedDepth` **seeds** expansion; it does not own it. The seeded set is @@ -124,7 +172,7 @@ present. ⛔ This does not make `tree` an authorable view type. objectui#5321 is unchanged: the block is written by a **host**, never by a document author, and -the authored node is the flat `object-tree` schema at the top of this file. +the authored node is the `object-tree` schema at the top of this file. ## License diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json index 6f982e60e9..b9c20f1750 100644 --- a/packages/plugin-tree/package.json +++ b/packages/plugin-tree/package.json @@ -46,6 +46,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@object-ui/sdui-parser": "workspace:*", "@object-ui/test-support": "workspace:*", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts index 5ddc9fa502..551a208c15 100644 --- a/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts +++ b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts @@ -102,6 +102,18 @@ * The rows below pin those readings; the "stays undeclared here" rows are * unchanged and now mean "owed, not yet executed". * + * ## ⭐ EXECUTED by objectui#11168 slice 3 — both owed verdicts + * + * That slice loaded `object-tree` into the repo-wide registry parity guard and + * aligned this mirror with the 17.5.0 row: `navigation` (the spec's + * `NavigationConfig`) and `tree` (the spec's `TreeConfig`) are declared on both + * faces, by reference, alongside `data`, `staticData` and an optional + * `objectName`. So the two "stays undeclared" rows below flipped to "declared", + * the ledger of undeclared reads is EMPTY, and the renderer's `navigation` read + * lost its cast. The flat `parentField` / `labelField` / `fields` / + * `defaultExpandedDepth` are untouched: still declared here, still read, and + * not declared by the spec row. + * * ## The ceiling, stated so nobody reads this file as claiming more * * `BaseSchema` ends in `[key: string]: any`. Typing the prop makes the question @@ -164,10 +176,12 @@ const CONTROL_KEY = 'zzplTreeAbsentControl8655' as const; * ③'s answers that are still UNDECLARED, by name. Every entry is asserted STILL * READ below: a ledger whose subject has gone is a hole, not an exemption * (objectui#8885). `filter` left this ledger when objectui#9549 executed its - * declare verdict — it is now a member of the mirror's shape, so the population - * row below counts it as declared rather than exempt. + * declare verdict, and `navigation` and `tree` left it when objectui#11168 + * slice 3 executed theirs — each is a member of the mirror's shape now, so the + * population row below counts it as declared rather than exempt. EMPTY: every + * key the renderer reads off the node is declared. */ -const LEDGERED_UNDECLARED = ['navigation', 'tree'] as const; +const LEDGERED_UNDECLARED: readonly string[] = []; /* ── Instruments ──────────────────────────────────────────────────────────── */ @@ -269,17 +283,24 @@ export type _LabelFieldIsDeclared = Expect>; export type _DefaultExpandedDepthIsDeclared = Expect>; -export type _NavigationIsUndeclared = Expect, false>>; +/** Declared by objectui#11168 slice 3 — the spec's `NavigationConfig`, by reference. */ +export type _NavigationIsDeclared = Expect>; /** Declared by objectui#9549 — the `filter` DECLARE verdict below, executed. */ export type _FilterIsDeclared = Expect>; -export type _TreeIsUndeclared = Expect, false>>; +/** Declared by objectui#11168 slice 3 — the spec's `TreeConfig`, by reference. */ +export type _TreeIsDeclared = Expect>; +/** …and the record-source rungs the 17.5.0 row declares beside it. */ +export type _StaticDataIsDeclared = Expect>; /** * ⚠️ THE CEILING. An undeclared key still compiles and still types `any`, so * declaring buys VALUE typing and never buys refusal of a misspelling. Both - * halves are pinned: the ledgered keys resolve to `any`, and so does a typo. + * halves are pinned: the control key resolves to `any`, and so does a typo — + * while a declared key (`navigation`, since objectui#11168 slice 3) no longer + * does, which is the value typing declaring bought. */ -export type _UndeclaredStillResolvesToAny = Expect>; +export type _UndeclaredStillResolvesToAny = Expect>; +export type _DeclaredNoLongerResolvesToAny = Expect, false>>; export type _MisspellingStillAdmitted = Expect>; /** The node still type-checks the way a host writes it, with no cast. */ @@ -375,7 +396,7 @@ describe('objectui#8655 — the spec has no element schema for this node', () => }); }); -describe('objectui#8655 — `navigation` is objectui#8652\'s, and its unlock HAS happened', () => { +describe('objectui#8655 — `navigation` is objectui#8652\'s, its unlock HAS happened, and objectui#11168 mirrored it', () => { it('the spec declares it on seven element entries since 17.5.0, this one included', () => { // Through 17.4.0: ONE entry, `object-grid`, and not this one. 17.5.0 met // objectui#8652's unlock criterion (objectui#11073); mirroring is that @@ -391,8 +412,9 @@ describe('objectui#8655 — `navigation` is objectui#8652\'s, and its unlock HAS ]); }); - it('⛔ and it stays UNDECLARED here — this card must not rule the family', () => { - expect(shapeKeys(ObjectTreeMirror)).not.toContain('navigation'); + it('and the mirror declares it now — objectui#11168 slice 3 executed the ruled second step', () => { + // This row stood as `not.toContain` while the mirroring was owed. + expect(shapeKeys(ObjectTreeMirror)).toContain('navigation'); }); }); @@ -426,7 +448,7 @@ describe('objectui#8655 — `filter` is the one DECLARE verdict, executed by obj }); }); -describe('objectui#8655 — `tree` is a VIEW-level block, so declaring it on the node would fork', () => { +describe('objectui#8655 — `tree` was a VIEW-level block, and since 17.5.0 the node\'s own (declared by objectui#11168)', () => { it('the spec declares the block, and its members are the four this resolver reads', () => { const blockKeys = Object.keys( (TreeConfigSchema as unknown as { shape: Record }).shape, @@ -442,8 +464,9 @@ describe('objectui#8655 — `tree` is a VIEW-level block, so declaring it on the expect(specElementsDeclaring('filter').length).toBeGreaterThan(3); }); - it('⛔ so it is not declared here either', () => { - expect(shapeKeys(ObjectTreeMirror)).not.toContain('tree'); + it('so the mirror declares it — objectui#11168 slice 3 executed the DECLARE verdict', () => { + // This row stood as `not.toContain` while the verdict was owed. + expect(shapeKeys(ObjectTreeMirror)).toContain('tree'); }); }); diff --git a/packages/plugin-tree/src/ObjectTree.test.tsx b/packages/plugin-tree/src/ObjectTree.test.tsx index 42c2816de1..a2b46a8505 100644 --- a/packages/plugin-tree/src/ObjectTree.test.tsx +++ b/packages/plugin-tree/src/ObjectTree.test.tsx @@ -122,7 +122,10 @@ describe('ObjectTree', () => { ]; render( , ); diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index d90dce235e..115d1839f0 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -1044,23 +1044,14 @@ export const ObjectTree: React.FC = ({ }); const navigation = useNavigationOverlay({ - // ⛔ The cast STAYS, and it stays on purpose (objectui#8655). This is the - // card's other class-(d) read, and typing the node made it answerable: - // measured with `checker.getPropertyOfType` against the node type, this key - // is UNDECLARED — it survives only on `BaseSchema`'s `[key: string]: any`. - // Dropping the cast would compile through that index signature and type - // `any` with nothing marking it, which is the defect rather than the - // absence of one (objectui#8651 records that shape). - // - // ⛔ And this card does NOT rule it. `navigation` is objectui#8652's - // family: maintainer-ruled option B — declare on the PLATFORM element - // schemas first, then mirror — blocked on objectstack `e233db9db`, whose unlock - // criterion is a released `@objectstack/spec` carrying the declaration - // being installable here. Measured on the installed spec: `navigation` is - // declared on exactly one `ComponentPropsMap` entry, `object-grid`, and - // this element has no entry at all. ⇒ not declared here, not retired here, - // read untouched. - navigation: (schema as any).navigation, + // A DECLARED read since objectui#11168 slice 3. objectui#8655 kept a cast + // here on purpose: the key was undeclared on the node and survived only on + // `BaseSchema`'s `[key: string]: any`, and objectui#8652's family ruling + // (option B — the platform element schemas first, then the mirror) was + // waiting on a released spec. `@objectstack/spec` 17.5.0 declares + // `navigation` on the `object-tree` row, and `ObjectTreeSchema` now mirrors + // it as the spec's `NavigationConfig`, so the read is typed. + navigation: schema.navigation, // The record-page URL names the object the ROWS came from, not the block's // bare top-level key (`2ce2612df`). `77cb489b4` published `objectName` // as the THIRD RUNG of ONE record-source ladder (`data`, then `staticData`, diff --git a/packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx b/packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx new file mode 100644 index 0000000000..37062d9735 --- /dev/null +++ b/packages/plugin-tree/src/__tests__/objectTreeInputs-11168.test.tsx @@ -0,0 +1,664 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11168 slice 3 — what `object-tree` PUBLISHES, and what its renderer + * does with each member of each structured key. + * + * `@objectstack/spec` 17.5.0 gave `object-tree` a `ComponentPropsMap` row + * (`objectName`, `data`, `staticData`, `filter`, `tree`, `navigation`), and the + * repo-wide parity guard + * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) now loads + * this plugin, so it judges the registration in both directions. Under + * objectui#11111 decision 3 = B every key is decided by its own measurement: + * declare what the renderer honours. This file is that measurement, kept as a + * pin. Every behavioural row mounts the block the way a page does — through + * the REAL `SchemaRenderer` and this package's own registration — and asserts + * on what a user sees, on the query the adapter is handed, or on what a click + * opened. Every positive row carries its control. + * + * What moved on the registration (both tags, `object-tree` and `view:tree`): + * + * - `objectName` is no longer REQUIRED. The record source is one of `data`, + * `staticData` and `objectName`; a tree on inline rows never reads it, and + * the page validator raised `missing-required-prop` on a tree the spec row + * and the renderer both accept. + * - `data`, `staticData`, `filter` and `navigation` are declared. The page + * validator reported each as `unknown-prop` while the renderer honoured it. + * + * The member contract of each structured key is the read site, because the + * spec cannot supply it: `staticData` is `z.array(z.unknown())`, and the + * others fix member NAMES and nothing about how a member is used once read. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { + RelatedRecordActionsProvider, + SchemaRenderer, + SchemaRendererProvider, + type RelatedRecordActionsValue, +} from '@object-ui/react'; +import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { safeValidateSchema } from '@object-ui/types/zod'; +// Registers `object-tree` and `view:tree` through this package's own entry, at +// module scope (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../index'; + +const TAGS = [ + { type: 'object-tree', namespace: 'plugin-tree' }, + { type: 'tree', namespace: 'view' }, +] as const; + +/** + * Three records in a two-level hierarchy. `owner` is drawn by no column the + * rows below configure, only by the record overlay that lists the clicked + * record's fields — so its text on screen means "the row's record opened". + */ +const ROWS = [ + { id: 'r1', name: 'Acme', parent_id: null, status: 'active', owner: 'Ada Lovelace' }, + { id: 'r2', name: 'Engineering', parent_id: 'r1', status: 'active', owner: 'Grace Hopper' }, + { id: 'r3', name: 'Legacy Ops', parent_id: 'r1', status: 'archived', owner: 'Alan Turing' }, +]; +const OPENED_RECORD_TEXT = 'Ada Lovelace'; +const OBJECT = 'business_unit'; +const TREE = { parentField: 'parent_id', labelField: 'name' }; + +function makeDataSource() { + return { + find: vi.fn(async (_object: string, _query?: Record) => ({ + data: [{ id: 'q1', name: 'Queried root', parent_id: null }], + total: 1, + })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: OBJECT, + // `owner` is declared so the record overlay's typed panel draws it. + fields: { name: { type: 'text' }, parent_id: { type: 'text' }, owner: { type: 'text', label: 'Owner' } }, + })), + }; +} + +const mount = ( + schema: Record, + ds = makeDataSource(), + hostProps: Record = {}, +) => { + render( + + + , + ); + return ds; +}; + +/** Each drawn row as `label@depth`, read once the tree has left its placeholder. */ +async function drawn(): Promise { + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + return screen + .queryAllByTestId('object-tree-row') + .map((row) => `${row.querySelector('.truncate')?.textContent ?? ''}@${row.getAttribute('data-depth')}`); +} + +/** The header cells, in order. */ +const headers = () => [...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim()); + +/** The diagnostics the page validator raises, over the manifest the registry publishes. */ +const diagnose = (node: Record) => + validateTree( + node as never, + manifestFromConfigs( + ComponentRegistry.getKnownTypes().map((type) => { + const meta = ComponentRegistry.getMeta(type); + return { type, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; + }) as unknown as Parameters[0], + ), + ).diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.message]); + +const inputsOf = (type: string, namespace: string) => + new Map( + (((ComponentRegistry.getConfig(type, namespace) as { inputs?: Array> } | undefined) + ?.inputs) ?? []).map((input) => [input.name as string, input]), + ); + +const specRow = (ComponentPropsMap as unknown as Record { success: boolean } }>)[ + 'object-tree' +]; + +beforeEach(() => { + // Best-effort metadata probes are not what these rows are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); + // A drag-resized width persisted in localStorage would mask every width row. + try { window.localStorage.clear(); } catch { /* private mode */ } +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +// ── The registration ──────────────────────────────────────────────────────── + +describe('object-tree publishes the keys its renderer honours (objectui#11168)', () => { + it.each(TAGS)('$type — declares the record source, `filter` and `navigation`, with the spec row\'s kinds', ({ type, namespace }) => { + const inputs = inputsOf(type, namespace); + expect([...inputs.keys()].sort()).toEqual( + ['data', 'filter', 'navigation', 'objectName', 'staticData', 'tree'], + ); + expect(inputs.get('objectName')?.required).toBeUndefined(); + expect(inputs.get('data')?.type).toBe('object'); + expect(inputs.get('staticData')?.type).toBe('array'); + expect(inputs.get('filter')?.type).toBe('array'); + expect(inputs.get('navigation')?.type).toBe('object'); + expect(inputs.get('tree')?.type).toBe('object'); + }); + + it.each(TAGS)('$type — the page validator accepts a tree on inline rows alone, and on a `data` configuration alone', ({ type }) => { + // Before this slice: `missing-required-prop` on `objectName` (an error) + // and `unknown-prop` on `staticData`, for a tree that draws. + expect(diagnose({ type, tree: TREE, staticData: ROWS })).toEqual([]); + expect(diagnose({ type, tree: TREE, data: { provider: 'value', items: ROWS } })).toEqual([]); + expect( + diagnose({ + type, + objectName: OBJECT, + filter: [{ field: 'status', operator: 'equals', value: 'active' }], + navigation: { mode: 'drawer' }, + }), + ).toEqual([]); + }); + + it.each(TAGS)('$type — CONTROL: a bogus key is still reported', ({ type }) => { + expect(diagnose({ type, objectName: OBJECT, bogusProp: 1 })).toEqual([ + ['unknown-prop', `<${type}> has no prop "bogusProp"`], + ]); + }); + + it('the installed spec row accepts every shape the rows below author', () => { + expect(specRow.safeParse({ tree: TREE, staticData: ROWS }).success).toBe(true); + expect(specRow.safeParse({ data: { provider: 'value', items: ROWS } }).success).toBe(true); + expect( + specRow.safeParse({ + objectName: OBJECT, + filter: [{ field: 'status', operator: 'equals', value: 'active' }], + navigation: { mode: 'drawer', size: 'lg' }, + }).success, + ).toBe(true); + // CONTROL: the row is not accepting everything. + expect(specRow.safeParse({ objectName: OBJECT, bogusProp: 1 }).success).toBe(false); + }); +}); + +// ── The authoring door ───────────────────────────────────────────────────── + +/** What `objectui validate` prints for one node: `safeValidateSchema`'s issues, as `{ code, path }`. */ +const authoringIssues = (node: Record) => { + const result = safeValidateSchema({ type: 'object-tree', ...node }); + return result.success + ? [] + : result.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join('.'), + ...((issue as { params?: { code?: string } }).params?.code + ? { params: (issue as { params?: { code?: string } }).params!.code } + : {}), + })); +}; + +describe('the authoring door `objectui validate` reads — `ObjectTreeSchema` (objectui#11168)', () => { + it('accepts a tree on inline rows alone, on a `data` configuration alone, and with `tree` + `navigation`', () => { + // Before this slice the first two were refused: `invalid_type` at + // `objectName`, on trees that draw (the rows above). + expect(authoringIssues({ tree: TREE, staticData: ROWS })).toEqual([]); + expect(authoringIssues({ tree: TREE, data: { provider: 'value', items: ROWS } })).toEqual([]); + expect(authoringIssues({ objectName: OBJECT, tree: TREE, navigation: { mode: 'drawer', size: 'lg' } })).toEqual([]); + }); + + it('refuses a tree with NO record source, once, at the root — the rule the required member used to carry', () => { + expect(authoringIssues({ tree: TREE })).toEqual([ + { code: 'custom', path: '', params: 'RECORD_SOURCE_REQUIRED' }, + ]); + }); + + it('the node\'s `dataSource` binding is NOT a rung here — the registration is not gate-wrapped, so it lands nowhere', () => { + expect(authoringIssues({ tree: TREE, dataSource: { object: OBJECT } })).toEqual([ + { code: 'custom', path: '', params: 'RECORD_SOURCE_REQUIRED' }, + ]); + }); + + it('refuses a value the spec\'s blocks refuse, by reference: a misspelled `tree` member and an unknown `mode`', () => { + expect(authoringIssues({ objectName: OBJECT, tree: { parentFeild: 'parent_id' } }).map((i) => i.path)).toEqual(['tree']); + expect(authoringIssues({ objectName: OBJECT, navigation: { mode: 'sideways' } }).map((i) => i.path)).toEqual(['navigation.mode']); + }); +}); + +// ── tree ──────────────────────────────────────────────────────────────────── + +describe('`object-tree.tree` — the members are field names the tree draws by (objectui#11168)', () => { + it('`parentField` names the field whose value is the parent id: a child nests one level under it', async () => { + mount({ tree: TREE, staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + }); + + it('CONTROL: naming a field that holds no parent id leaves every record a root', async () => { + mount({ tree: { ...TREE, parentField: 'status' }, staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@0', 'Legacy Ops@0']); + }); + + it('`labelField` names the field drawn in the first column, and an absent one reads `name`', async () => { + mount({ tree: { ...TREE, labelField: 'owner' }, staticData: ROWS }); + expect(await drawn()).toEqual(['Ada Lovelace@0', 'Grace Hopper@1', 'Alan Turing@1']); + cleanup(); + mount({ tree: { parentField: 'parent_id' }, staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + }); + + it('`fields` adds one flat column per member, after the label column', async () => { + mount({ tree: { ...TREE, fields: ['status'] }, staticData: ROWS }); + await drawn(); + expect(headers()).toEqual(['Name', 'Status']); + expect(screen.getAllByText('archived')).toHaveLength(1); + cleanup(); + // CONTROL: without it there is the label column alone. + mount({ tree: TREE, staticData: ROWS }); + await drawn(); + expect(headers()).toEqual(['Name']); + expect(screen.queryByText('archived')).toBeNull(); + }); + + it('`defaultExpandedDepth: 0` opens roots only; absent, every level is open', async () => { + mount({ tree: { ...TREE, defaultExpandedDepth: 0 }, staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0']); + cleanup(); + mount({ tree: TREE, staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + }); + + it('measured, not endorsed: a FLAT key of the same name on the node outranks the member', async () => { + // `getTreeConfig` reads `schema.parentField ?? tree.parentField`, member by + // member. The flat keys are the runtime handoff a host composes; the spec + // row refuses them, and the registration does not publish them. + mount({ tree: { ...TREE, parentField: 'status' }, parentField: 'parent_id', staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + }); +}); + +// ── data / staticData ─────────────────────────────────────────────────────── + +describe('`object-tree.data` — the members are the `{ provider, … }` configurations (objectui#11168)', () => { + it('`{ provider: "value", items }` draws those records and queries nothing', async () => { + const ds = mount({ tree: TREE, data: { provider: 'value', items: ROWS } }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('`{ provider: "object", object }` queries THAT object, and draws what it returns', async () => { + const ds = mount({ tree: TREE, data: { provider: 'object', object: OBJECT } }); + expect(await drawn()).toEqual(['Queried root@0']); + expect(ds.find).toHaveBeenCalledWith(OBJECT, expect.anything()); + }); + + it.each([ + ['api', { provider: 'api', read: { url: '/api/units' } }], + ['schema', { provider: 'schema', schemaId: 'units' }], + ])('`{ provider: "%s" }` draws nothing and queries nothing — the tree reads neither', async (_label, data) => { + expect(specRow.safeParse({ data }).success, 'the spec row accepts the configuration').toBe(true); + const ds = mount({ tree: TREE, data }); + expect(await drawn()).toEqual([]); + expect(screen.getByText('No records')).toBeInTheDocument(); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('it is read FIRST: a `data` configuration wins over `staticData` and over `objectName`', async () => { + const ds = mount({ + tree: TREE, + objectName: OBJECT, + staticData: [{ id: 's1', name: 'Static root', parent_id: null }], + data: { provider: 'value', items: ROWS }, + }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); + +describe('`object-tree.staticData` — the members are RECORDS (objectui#11168)', () => { + it('each record is placed under the record its parent-id value names, and one naming no record is a root', async () => { + mount({ + tree: TREE, + staticData: [...ROWS, { id: 'r4', name: 'Orphan', parent_id: 'missing' }], + }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1', 'Orphan@0']); + }); + + it('it is read SECOND, above `objectName`: a tree carrying both draws these rows and never queries the object', async () => { + const ds = mount({ tree: TREE, objectName: OBJECT, staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + expect(ds.find).not.toHaveBeenCalled(); + cleanup(); + // CONTROL: the same node without the inline rows queries the object, so + // "never queries" above is not a harness that cannot query. + const live = mount({ tree: TREE, objectName: OBJECT }); + expect(await drawn()).toEqual(['Queried root@0']); + expect(live.find).toHaveBeenCalledWith(OBJECT, expect.anything()); + }); +}); + +// ── filter ────────────────────────────────────────────────────────────────── + +describe('`object-tree.filter` — the members are `{ field, operator, value }` rules (objectui#11168)', () => { + const ACTIVE = [{ field: 'status', operator: 'equals', value: 'active' }]; + + it('a rule narrows the inline records the tree draws', async () => { + mount({ tree: TREE, staticData: ROWS, filter: ACTIVE }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1']); + }); + + it('`operator` is read: `not_equals` draws the complement', async () => { + mount({ tree: TREE, staticData: ROWS, filter: [{ ...ACTIVE[0], operator: 'not_equals' }] }); + expect(await drawn()).toEqual(['Legacy Ops@0']); + }); + + it('a record whose parent the filter removed is drawn as a root, not dropped', async () => { + // `Legacy Ops` above is the same reading: its parent `Acme` is filtered out. + mount({ tree: TREE, staticData: ROWS, filter: [{ field: 'name', operator: 'equals', value: 'Engineering' }] }); + expect(await drawn()).toEqual(['Engineering@0']); + }); + + it('on the object query the rule reaches `$filter` with its members unchanged', async () => { + const ds = mount({ tree: TREE, objectName: OBJECT, filter: ACTIVE }); + await drawn(); + expect(ds.find).toHaveBeenCalledTimes(1); + expect(ds.find.mock.calls[0][1]?.$filter).toEqual(ACTIVE); + }); + + it('CONTROL: with no `filter` the query carries no `$filter` and the inline rows are all drawn', async () => { + const ds = mount({ tree: TREE, objectName: OBJECT }); + await drawn(); + expect(ds.find.mock.calls[0][1]?.$filter).toBeUndefined(); + cleanup(); + mount({ tree: TREE, staticData: ROWS }); + expect(await drawn()).toEqual(['Acme@0', 'Engineering@1', 'Legacy Ops@1']); + }); +}); + +// ── navigation ────────────────────────────────────────────────────────────── + +/** Mount one tree with an authored `navigation` (or none), click its root row, and hand back the `window.open` spy. */ +async function clickRoot(navigation: Record | undefined, hostProps: Record = {}) { + const open = vi.fn(); + vi.stubGlobal('open', open); + // `staticData` with `objectName` beside it: the inline rows are drawn and no + // query is issued, while the object still names the record page a new tab + // opens. + mount({ tree: TREE, objectName: OBJECT, staticData: ROWS, ...(navigation ? { navigation } : {}) }, makeDataSource(), hostProps); + await drawn(); + fireEvent.click(screen.getAllByTestId('object-tree-row')[0]); + return { open }; +} + +/** Give a click every chance to have done something before asserting it did not. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); +const dialog = () => document.querySelector('[role="dialog"]') as HTMLElement | null; +const openedRecord = () => screen.queryByText(OPENED_RECORD_TEXT); + +async function expectNothingOpened(open: ReturnType, why: string) { + await settle(); + expect(dialog(), `${why}: an overlay opened`).toBeNull(); + expect(openedRecord(), `${why}: the record was drawn`).toBeNull(); + expect(open, `${why}: a tab was opened`).not.toHaveBeenCalled(); +} + +/** The resolved overlay width, read off the `--ov-w` custom property the shared shell publishes. */ +function panelWidth(): string { + const panel = dialog(); + expect(panel, 'overlay panel').not.toBeNull(); + return panel!.style.getPropertyValue('--ov-w').trim(); +} + +describe('`object-tree.navigation` — the members decide what a row click opens (objectui#11168)', () => { + it('LIT CONTROL: `mode: "drawer"` opens the row\'s record in a drawer', async () => { + const { open } = await clickRoot({ mode: 'drawer' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(openedRecord()).not.toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it.each(['modal', 'popover'] as const)('`mode: "%s"` opens the row\'s record in that overlay', async (mode) => { + const { open } = await clickRoot({ mode }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(openedRecord()).not.toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it('`mode: "split"` opens the record BESIDE the tree, which stays drawn', async () => { + // Unlike the timeline, the tree hands the split shell its own view as the + // main panel (objectui#9299 item 2). + const { open } = await clickRoot({ mode: 'split' }); + await waitFor(() => expect(openedRecord()).not.toBeNull()); + expect(screen.getAllByTestId('object-tree-row').length).toBeGreaterThan(0); + expect(open).not.toHaveBeenCalled(); + }); + + it('`mode: "none"` opens nothing', async () => { + const { open } = await clickRoot({ mode: 'none' }); + await expectNothingOpened(open, 'none'); + }); + + it('`preventNavigation: true` OUTRANKS an overlay mode', async () => { + const { open } = await clickRoot({ mode: 'drawer', preventNavigation: true }); + await expectNothingOpened(open, 'preventNavigation'); + }); + + it('`mode: "new_window"` opens the record page of the node\'s object in a new tab, and no overlay', async () => { + const { open } = await clickRoot({ mode: 'new_window' }); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + expect(open).toHaveBeenCalledWith(`/${OBJECT}/record/r1`, '_blank'); + expect(dialog()).toBeNull(); + }); + + it('`openNewTab: true` OUTRANKS `page` and an overlay mode', async () => { + const page = await clickRoot({ mode: 'page', openNewTab: true }); + await waitFor(() => expect(page.open).toHaveBeenCalledWith(`/${OBJECT}/record/r1`, '_blank')); + cleanup(); + const drawer = await clickRoot({ mode: 'drawer', openNewTab: true }); + await waitFor(() => expect(drawer.open).toHaveBeenCalledTimes(1)); + expect(dialog()).toBeNull(); + }); + + it('…but NOT `none`: `none` is read first, like `preventNavigation`', async () => { + const { open } = await clickRoot({ mode: 'none', openNewTab: true }); + await expectNothingOpened(open, 'none beside openNewTab'); + }); + + it('`size` and `width` are one width decision: `width` wins, a bucket resolves, `auto` lands on the default', async () => { + await clickRoot({ mode: 'drawer' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + const unsized = panelWidth(); + cleanup(); + await clickRoot({ mode: 'drawer', size: 'lg' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + const large = panelWidth(); + expect(large).not.toBe(unsized); + cleanup(); + await clickRoot({ mode: 'drawer', size: 'sm', width: '720px' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(panelWidth()).toContain('720px'); + cleanup(); + await clickRoot({ mode: 'drawer', size: 'auto' }); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(panelWidth()).toBe(unsized); + }); + + it('a parent view\'s click handler OUTRANKS the whole key, overlay mode included', async () => { + const onRowClick = vi.fn(); + const { open } = await clickRoot({ mode: 'drawer' }, { onRowClick }); + await waitFor(() => expect(onRowClick).toHaveBeenCalledTimes(1)); + expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: 'r1' }); + await expectNothingOpened(open, 'parent handler'); + }); + + it('with the key ABSENT a click opens nothing — this renderer supplies no drawer default', async () => { + const { open } = await clickRoot(undefined); + await expectNothingOpened(open, 'absent key'); + }); + + it('with NO host navigator, `mode: "page"` and a block without `mode` have no record page to open, and open nothing', async () => { + const page = await clickRoot({ mode: 'page' }); + await expectNothingOpened(page.open, 'page'); + cleanup(); + // `size` alone, written to widen an overlay: the absent `mode` reads `page`. + const modeless = await clickRoot({ size: 'lg' }); + await expectNothingOpened(modeless.open, 'mode-less block'); + }); +}); + +/** + * A host that publishes its record navigator the way the console does on its + * custom pages, record pages and list views (`RelatedRecordActionsContext`): + * `openRecord` is the spy a `page` click must reach. The same host the + * board's and the calendar's `NavigationMembers-8652` pins mount. + */ +function recordNavigatorHost() { + const openRecord = vi.fn(); + const value: RelatedRecordActionsValue = { + resolve: () => ({}), + recordHref: (objectName, recordId) => `/apps/demo/${objectName}/record/${recordId}`, + openRecord, + }; + return { value, openRecord }; +} + +/** `clickRoot`, with the tree mounted through the real `SchemaRenderer` UNDER that host. */ +async function clickRootUnderHost( + navigation: Record | undefined, + host: RelatedRecordActionsValue, + { namesObject = true } = {}, +) { + const open = vi.fn(); + vi.stubGlobal('open', open); + render( + + + + + , + ); + await drawn(); + fireEvent.click(screen.getAllByTestId('object-tree-row')[0]); + return { open }; +} + +describe('`object-tree.navigation` `page` under the host\'s record navigator (objectui#11168, objectui#11293)', () => { + it('LIT CONTROL: under the same host, `mode: "drawer"` opens the drawer and does not navigate', async () => { + // First, because the rows below would be vacuous against a tree whose rows + // are not clickable under this host. + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickRootUnderHost({ mode: 'drawer' }, value); + await waitFor(() => expect(dialog()).not.toBeNull()); + expect(openRecord).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + }); + + it('`mode: "page"` opens the record page of the tree\'s object through the host, and no overlay', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickRootUnderHost({ mode: 'page' }, value); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, 'r1'); + expect(dialog()).toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it('a block WITHOUT `mode` resolves to `page`, and navigates the same way', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickRootUnderHost({ size: 'lg' }, value); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, 'r1'); + expect(dialog()).toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it.each([ + ['the key ABSENT', undefined], + ['`mode: "none"`', { mode: 'none' }], + ] as const)('%s still opens nothing under the host, and does not navigate', async (why, navigation) => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickRootUnderHost(navigation, value); + await expectNothingOpened(open, String(why)); + expect(openRecord).not.toHaveBeenCalled(); + }); + + it('a tree that names no object at all (inline rows, no `objectName`) has no record page to open, even under the host', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickRootUnderHost({ mode: 'page' }, value, { namesObject: false }); + await expectNothingOpened(open, 'page without objectName'); + expect(openRecord).not.toHaveBeenCalled(); + }); +}); + +/** + * Which object `page` opens is the object the ROWS came from: the tree hands + * the hook `resolveRecordSourceObjectName(schema, data) ?? objectName`, which + * reads `data.object` when `data` is the object provider and the node's + * `objectName` otherwise. These rows mount the `object-tree.data` rows' own + * object-provider shape under the same host. + */ +async function clickQueriedRootUnderHost(schema: Record, host: RelatedRecordActionsValue) { + const open = vi.fn(); + vi.stubGlobal('open', open); + const ds = makeDataSource(); + render( + + + + + , + ); + expect(await drawn()).toEqual(['Queried root@0']); + fireEvent.click(screen.getAllByTestId('object-tree-row')[0]); + return { open, ds }; +} + +describe('`object-tree.navigation` `page` opens the record page of the object the rows came from (objectui#11168)', () => { + it('`data: { provider: "object", object }` with NO `objectName`: `page` opens THAT object\'s record page', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { open } = await clickQueriedRootUnderHost( + { data: { provider: 'object', object: OBJECT }, navigation: { mode: 'page' } }, + value, + ); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, 'q1'); + expect(dialog()).toBeNull(); + expect(open).not.toHaveBeenCalled(); + }); + + it('both written: `data.object` wins over `objectName` — the rows\' object opens, and is the one queried', async () => { + const { value, openRecord } = recordNavigatorHost(); + const { ds } = await clickQueriedRootUnderHost( + { objectName: 'org_chart', data: { provider: 'object', object: OBJECT }, navigation: { mode: 'page' } }, + value, + ); + await waitFor(() => expect(openRecord).toHaveBeenCalledTimes(1)); + expect(openRecord).toHaveBeenCalledWith(OBJECT, 'q1'); + expect(ds.find).toHaveBeenCalledWith(OBJECT, expect.anything()); + expect(ds.find).not.toHaveBeenCalledWith('org_chart', expect.anything()); + }); +}); diff --git a/packages/plugin-tree/src/index.tsx b/packages/plugin-tree/src/index.tsx index 1d9438f0da..29fe0e6d1e 100644 --- a/packages/plugin-tree/src/index.tsx +++ b/packages/plugin-tree/src/index.tsx @@ -22,12 +22,59 @@ export const ObjectTreeRenderer: React.FC = ({ schema, ...props }) => { return ; }; +// The keys `@objectstack/spec`'s `object-tree` row declares, each published +// because `ObjectTree` was measured to honour it (objectui#11168 slice 3, +// objectui#11111 decision 3 = B). Every description names only what the +// renderer was measured to do with the key; the members of each structured key +// are pinned in `__tests__/objectTreeInputs-11168.test.tsx`, and a renderer +// that changes any of them reddens a row there — the fix is to rewrite the +// description with it. +// +// `objectName` is NOT required: the record source is one of `data`, +// `staticData` and `objectName`, read in that order, so a tree on inline rows +// never reads it. Required, the page validator raised `missing-required-prop` +// on a tree the spec row and the renderer both accept. The list is a flat +// declaration with a boolean `required`, so it states the rule in the +// description rather than growing a one-of form — the `object-map` / +// `object-gantt` precedent (objectui#7470). +// +// `view:tree` is the same renderer under a second tag, so it shares the list. const treeInputs = [ - { name: 'objectName', type: 'string' as const, required: true }, + { + name: 'objectName', + type: 'string' as const, + description: + 'ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`, read in that order: a tree on `data` or `staticData` never queries this object. On a tree whose rows are inline it still names the record page a new-tab click opens.', + }, { name: 'tree', type: 'object' as const, - description: 'parentField, labelField, fields, defaultExpandedDepth', + description: + 'The field configuration: `parentField` names the field holding each record’s parent id (auto-detected from the object’s self-referencing field when omitted), `labelField` the field drawn indented in the first column (default `name`), `fields` the further fields drawn as flat columns after it, and `defaultExpandedDepth` how many levels open on first draw (`0` shows roots only; omitted, every level is open).', + }, + { + name: 'data', + type: 'object' as const, + description: + 'A `{ provider, … }` data-source configuration, read FIRST on the record-source ladder: a tree carrying one never reaches `staticData` and never queries `objectName`. `{ provider: \'value\', items }` draws those records and `{ provider: \'object\', object }` queries that object, both narrowed by `filter`. The `api` and `schema` providers draw no rows on the tree. A bare array is not this key’s shape and is not a record source: the tree falls through to `staticData`, then `objectName`, so inline rows belong under `staticData`.', + }, + { + name: 'staticData', + type: 'array' as const, + description: + 'Inline records, read SECOND on the record-source ladder: a `data` configuration wins and this key is then never reached, while `objectName` is read AFTER it, so a tree carrying both draws these rows and never queries that object. Each record is placed under the record its `parentField` value names, and one naming no record is drawn as a root. `filter` narrows these rows exactly as it does fetched ones.', + }, + { + name: 'filter', + type: 'array' as const, + description: + 'Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the records the tree draws — fetched rows and inline (`staticData`, `{ provider: \'value\' }`) rows alike. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. A record whose parent the filter removed is drawn as a root. The MongoDB-style record form is not this key’s shape.', + }, + { + name: 'navigation', + type: 'object' as const, + description: + 'What a row click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens nothing: this renderer supplies no drawer default. `mode` is an overlay (`drawer`, `modal`, `split`, `popover`), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `page` default. `drawer`, `modal` and `popover` open the row’s record in that overlay, and `split` opens it beside the tree; `new_window` opens the record page in a new tab; `none` opens nothing. `page` opens the record page of `data.object` when `data` is the object provider, else of the tree’s `objectName`, through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, or on a tree that names neither (inline rows with no `objectName`), there is no record page to open and the click opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.', }, ]; diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 7259ed89e7..0aaff0f75f 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -704,7 +704,7 @@ derived from the child object's metadata unless you override them ### View tabs There is no `tabs` key, and `form.layout` has no tabbed value -(`vertical | horizontal | inline | grid`). The tab strip this package ships is +(`vertical | horizontal`). The tab strip this package ships is the **saved-view** tab bar: declare the views and render `` (or let a host such as `@object-ui/app-shell` do it): diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 50d5beaee1..2378a1ea6f 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -66,6 +66,7 @@ import { CalendarConfigSchema as SpecCalendarConfigSchema, GalleryConfigSchema as SpecGalleryConfigSchema, TimelineConfigSchema as SpecTimelineConfigSchema, + TreeConfigSchema as SpecTreeConfigSchema, HttpMethodSubsetSchema as SpecHttpMethodSubsetSchema, HttpRequestSchema as SpecHttpRequestSchema, ViewDataSchema as SpecViewDataSchema, @@ -214,6 +215,9 @@ const IMPORTED: Array = [ ['CalendarConfigSchema', SpecCalendarConfigSchema], ['GalleryConfigSchema', SpecGalleryConfigSchema], ['TimelineConfigSchema', SpecTimelineConfigSchema], + // objectui#11168 slice 3: `ObjectTreeSchema.tree` is the spec's tree block, + // crossed through this boundary like the other view-config blocks above. + ['TreeConfigSchema', SpecTreeConfigSchema], ['HttpMethodSubsetSchema', SpecHttpMethodSubsetSchema], ['HttpRequestSchema', SpecHttpRequestSchema], ['ViewDataSchema', SpecViewDataSchema], diff --git a/packages/types/src/__tests__/object-tree-root-barrel-9550.test.ts b/packages/types/src/__tests__/object-tree-root-barrel-9550.test.ts index 9324b9da4c..a5236f6c95 100644 --- a/packages/types/src/__tests__/object-tree-root-barrel-9550.test.ts +++ b/packages/types/src/__tests__/object-tree-root-barrel-9550.test.ts @@ -143,7 +143,11 @@ describe('objectui#9550 - `ObjectTreeSchema` resolves from the root barrel', () // reason for this one to red, while a member RETYPED here would be exactly // the "nothing retyped or narrowed" claim breaking. const tag: Eq = true; - const objectName: Eq = true; + // `string | undefined` since objectui#11168 slice 3: the member went + // OPTIONAL (a tree on inline rows never reads it; the zod twin's + // `requireRecordSource` refinement carries "one of the three"), measured + // against `@objectstack/spec` 17.5.0's `object-tree` row and the renderer. + const objectName: Eq = true; const parentField: Eq = true; const labelField: Eq = true; const fields: Eq = true; diff --git a/packages/types/src/__tests__/ui-action-size-11168.test.ts b/packages/types/src/__tests__/ui-action-size-11168.test.ts new file mode 100644 index 0000000000..8e4f14ba37 --- /dev/null +++ b/packages/types/src/__tests__/ui-action-size-11168.test.ts @@ -0,0 +1,54 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11168 slice 3 — `UIActionSchema.size` is the `action:button` row's + * own vocabulary, by reference. + * + * The type was `'sm' | 'md' | 'lg'` while `@objectstack/spec` 17.5.0's + * `action:button` row declares `default` / `sm` / `lg` / `icon` / `md`, the + * registration has published all five since slice 2, and the renderers draw + * all five — on the leaf and on a group member alike (measured through the real + * `SchemaRenderer` for this slice). So `size: 'icon'` on a typed action was a + * TS2322 the platform does not mean. The compile-time rows below are checked by + * `tsc -p tsconfig.test.json`; the runtime rows read the installed row. + */ +import { describe, it, expect } from 'vitest'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import type { ActionButtonProps } from '@objectstack/spec/ui'; +import type { UIActionSchema } from '../ui-action'; + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** The member IS the row's, so a spec release that moves the vocabulary moves this type with it. */ +export type _SizeIsTheRowsVocabulary = Expect>; +/** The two values the old union refused are accepted now. */ +const _default: UIActionSchema['size'] = 'default'; +const _icon: UIActionSchema['size'] = 'icon'; +// @ts-expect-error — CONTROL: a size no layer draws is still refused. +const _bogus: UIActionSchema['size'] = 'xl'; + +interface Parser { + safeParse: (value: unknown) => { success: boolean }; +} +const actionButtonRow = (ComponentPropsMap as unknown as Record)['action:button']; + +describe('`UIActionSchema.size` takes the `action:button` row\'s vocabulary (objectui#11168)', () => { + it.each(['default', 'sm', 'md', 'lg', 'icon'])('the installed row accepts `%s`', (size) => { + expect(actionButtonRow.safeParse({ size }).success).toBe(true); + }); + + it('CONTROL: the row refuses a size outside it', () => { + expect(actionButtonRow.safeParse({ size: 'xl' }).success).toBe(false); + }); + + it('the compile-time rows are real values', () => { + expect([_default, _icon, _bogus]).toEqual(['default', 'icon', 'xl']); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 982594f957..6de4898a8d 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4819,6 +4819,11 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // gained the same member and were already spec-derived. 'objectql.zod.ts#ObjectKanbanSchema', 'objectql.zod.ts#ObjectMapSchema', + // objectui#11168 slice 3: `tree` and `navigation` are the spec's `TreeConfigSchema` + // and `NavigationConfigSchema` by reference — the two blocks the `object-tree` row + // declares — the first spec references in this mirror. So a spec bump that moves + // either vocabulary moves ONE side of this pair. + 'objectql.zod.ts#ObjectTreeSchema', // objectui#7779: BACK, by a real code reference this time — `navigation`, // `searchableFields` and `filterableFields` are `SpecListViewSchema.shape.*` // by reference (identity-pinned in `object-view-unmirrored-keys-7779.test.ts`). diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index a27165a7e3..3b3730ade7 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -145,6 +145,10 @@ import type { // type from this package's mirror like the other sixteen. ListView as SpecListView, CalendarConfig as SpecCalendarConfig, + // objectui#11168 slice 3 — the `object-tree` element's `tree` block, so + // `ObjectTreeSchema.tree` below takes the protocol's own type. Aliased for + // the reason `SpecGanttConfig` above is. + TreeConfig as SpecTreeConfig, // objectui#9239 — `ComponentPropsMap['object-calendar']`'s author state, so // `ObjectCalendarSchema.data` below DERIVES the protocol's `data` row rather // than re-spelling it. Aliased because the bare name is the protocol's, and a @@ -3414,8 +3418,45 @@ export interface ObjectMapSchema extends BaseSchema { */ export interface ObjectTreeSchema extends BaseSchema { type: 'object-tree'; - /** ObjectQL object name */ - objectName: string; + /** + * ObjectQL object name — the THIRD record source `resolveRecordSourceConfig` + * reads, after {@link ObjectTreeSchema.data} and + * {@link ObjectTreeSchema.staticData}. + * + * OPTIONAL since objectui#11168 slice 3, as on {@link ObjectMapSchema} and + * {@link ObjectGanttSchema}: a tree on inline rows never reads it, and the + * zod twin's `requireRecordSource` refinement is where "one of the three" is + * enforced. ⚠️ The node's `dataSource` binding is NOT a fourth rung here — + * this block's registration is not gate-wrapped, so nothing lands a binding's + * object on this key. + */ + objectName?: string; + /** + * Data source configuration — read FIRST by `resolveRecordSourceConfig`, so a + * tree carrying one never reaches `staticData` or `objectName`. The `value` + * provider draws its `items` and the `object` provider queries its `object`; + * the `api` and `schema` providers draw no rows on the tree (objectui#11168 + * slice 3, measured). A bare array here is not a record source + * (objectui#8348). + */ + data?: ViewData; + /** Inline records, wrapped into a `{ provider: 'value' }` data config; read SECOND */ + staticData?: any[]; + /** + * The tree's field configuration — the spec's `TreeConfig`, by reference + * (objectui#11168 slice 3; the `tree` DECLARE verdict `ObjectTree.schemaTyped-8655` + * recorded as owed). Its four members are the four below; where both are + * written, the flat key on the node outranks the member of the same name + * (`getTreeConfig`). + */ + tree?: SpecTreeConfig; + /** + * Row-click navigation — the spec's `NavigationConfig`, the same block + * `ListViewSchema.navigation` declares (objectui#11168 slice 3, the tree arm + * of objectui#8652's ruling 「B」). An absent key opens nothing on a tree no + * parent view navigates for; see the registration description for each mode. + */ + navigation?: ViewNavigationConfig; /** * Query filter, forwarded as `$filter` on the tree's own fetch, with its * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666). diff --git a/packages/types/src/ui-action.ts b/packages/types/src/ui-action.ts index 854871050a..da111acbc5 100644 --- a/packages/types/src/ui-action.ts +++ b/packages/types/src/ui-action.ts @@ -21,6 +21,9 @@ import type { z } from 'zod'; import type { Action as SpecAction, + // objectui#11168 slice 3 — the `action:button` row's author state, so + // `UIActionSchema.size` takes the row's own vocabulary by reference. + ActionButtonProps as SpecActionButtonProps, ActionLocation, ActionParamSchema as SpecActionParamSchema, ActionType as SpecActionType, @@ -860,8 +863,22 @@ export interface UIActionSchema { /** Button variant */ variant?: 'default' | 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost'; - /** Button size */ - size?: 'sm' | 'md' | 'lg'; + /** + * Button size — the vocabulary `@objectstack/spec`'s `action:button` row + * declares, by reference: the Button primitive's `default`, `sm`, `lg` and + * `icon`, plus `md`, which renders as `default`. + * + * objectui#11168 slice 3: this was `'sm' | 'md' | 'lg'`, so `default` and + * `icon` — accepted by the row, published by the `action:button` + * registration since slice 2 and drawn as-is by the Button primitive — were + * TS2322 here. This type is ALSO the member type of the `action:group` / + * `action:menu` / `action:bar` lists, and the spec's member list is + * unconstrained, so the leaf's vocabulary is the one to take: measured through + * the real `SchemaRenderer`, a group MEMBER's `default` / `sm` / `md` / `lg` / + * `icon` render exactly as the same size on an `action:button` does (a + * member's own `md` is mapped to `default` by the group too). + */ + size?: SpecActionButtonProps['size']; /** Custom CSS class */ className?: string; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 7838eb8a64..7c842cc442 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -25,6 +25,9 @@ import { GalleryConfigSchema as SpecGalleryConfigSchema, GroupingConfigSchema as SpecGroupingConfigSchema, TimelineConfigSchema as SpecTimelineConfigSchema, + // objectui#11168 slice 3 — the `object-tree` element's `tree` block, by + // reference (`ObjectTreeSchema.tree` below). + TreeConfigSchema as SpecTreeConfigSchema, HttpMethodSubsetSchema as SpecHttpMethodSubsetSchema, HttpRequestSchema as SpecHttpRequestSchema, ViewDataSchema as SpecViewDataSchema, @@ -2255,10 +2258,18 @@ export const ObjectMapConfigSchema = z.object({ */ const RECORD_SOURCE_KEYS = ['data', 'staticData', 'objectName'] as const; type RecordSourceRung = 'bind' | 'data' | 'staticData' | 'objectName'; +/** + * `binding: 'not-read'` — for an arm whose registration is NOT gate-wrapped + * (objectui#11168 slice 3, `object-tree`). No `ElementDataSourceGate` lands a + * `dataSource.object` on that renderer's `objectName`, so the binding is not a + * record source there: counting it would accept a node that draws nothing, and + * naming it in the message would prescribe a write the renderer drops. + */ function requireRecordSource( - type: 'object-map' | 'object-gantt' | 'object-calendar' | 'object-kanban' | 'object-grid' | 'list-view', + type: 'object-map' | 'object-gantt' | 'object-calendar' | 'object-kanban' | 'object-grid' | 'list-view' | 'object-tree', rungs: readonly RecordSourceRung[], at: 'node' | 'properties' = 'node', + bindingRung: 'counts' | 'not-read' = 'counts', ) { const spell = (key: RecordSourceRung) => (at === 'properties' ? `properties.${key}` : key); const named = rungs.map((key) => `\`${spell(key)}\``); @@ -2268,14 +2279,16 @@ function requireRecordSource( const binding = at === 'properties' ? 'name the object in the node\'s `dataSource` binding (`dataSource.object`)' : 'name the object in `dataSource.object`'; - const message = `\`${type}\` has no record source: declare ${declare}, or ${binding}`; + const message = bindingRung === 'counts' + ? `\`${type}\` has no record source: declare ${declare}, or ${binding}` + : `\`${type}\` has no record source: declare ${declare}`; const path = rungs.length === 1 ? (at === 'properties' ? ['properties', rungs[0]] : [rungs[0]]) : []; const refinement = (node: unknown, ctx: z.core.$RefinementCtx): void => { if (!node || typeof node !== 'object' || Array.isArray(node)) return; const bag = (node as { properties?: unknown }).properties; const holder = at === 'node' ? node : bag && typeof bag === 'object' && !Array.isArray(bag) ? bag : {}; if (rungs.some((key) => (holder as Partial>)[key] !== undefined)) return; - if (dataSourceSuppliesObject(node)) return; + if (bindingRung === 'counts' && dataSourceSuppliesObject(node)) return; ctx.addIssue({ code: 'custom', path, @@ -2352,10 +2365,34 @@ const OBJECT_TREE_NEITHER_CHANNEL = neitherContentChannelGuidance( /** * ObjectTree (tree-grid) Schema + * + * objectui#11168 slice 3 aligned this mirror with the `object-tree` row + * `@objectstack/spec` 17.5.0 declares, member by member, each by measurement + * on `ObjectTree`: + * + * - `objectName` is OPTIONAL and the member ends in `requireRecordSource`, as + * on `ObjectMapSchema` / `ObjectGanttSchema`: the renderer resolves its + * record source through the shared ladder (`data`, then `staticData`, then + * `objectName`), so a tree on inline rows never reads the object name. + * Required, this face refused a `staticData`-only tree that draws — and + * `objectui validate` prints exactly this face's verdict. The binding is + * NOT a rung here: the tree's registration is not gate-wrapped, so a + * `dataSource.object` reaches no `objectName`. + * - `data`, `staticData`, `tree` and `navigation` are declared, each read by + * the renderer. `tree` and `navigation` take the spec's own schemas by + * reference, so this face cannot fork from the row. + * + * The flat `parentField` / `labelField` / `fields` / `defaultExpandedDepth` + * below stay as they were: the renderer still reads them, ahead of the `tree` + * block's members of the same name. The spec row does not declare them. */ export const ObjectTreeSchema = BaseSchema.extend({ type: z.literal('object-tree'), - objectName: z.string().describe('ObjectQL object name'), + objectName: z.string().optional().describe('ObjectQL object name — the THIRD record source resolveRecordSourceConfig resolves, after data and staticData; one of the three must be present'), + data: ViewDataSchema.optional().describe('Data source configuration — read FIRST by resolveRecordSourceConfig; the value and object providers draw rows, the api and schema providers draw none on the tree'), + staticData: z.array(z.any()).optional().describe('Inline records — read SECOND by resolveRecordSourceConfig, wrapped into a { provider: value } config'), + tree: stripImportedDefaults(SpecTreeConfigSchema).optional().describe('Tree field configuration (parentField, labelField, fields, defaultExpandedDepth) — the spec TreeConfig, by reference'), + navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Row-click navigation — the spec NavigationConfig, by reference; an absent key opens nothing on a standalone tree'), // objectui#9549 — declared in step with the twin in `../objectql.ts` // (`QueryParams['$filter']`), spelled exactly as `ObjectGallerySchema.filter` // below spells it (objectui#9309): the two arms of that slot, ARRAY FIRST. @@ -2373,7 +2410,7 @@ export const ObjectTreeSchema = BaseSchema.extend({ // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. body: retirementTombstone(OBJECT_TREE_NEITHER_CHANNEL), children: retirementTombstone(OBJECT_TREE_NEITHER_CHANNEL), -}); +}).superRefine(...requireRecordSource('object-tree', RECORD_SOURCE_KEYS, 'node', 'not-read')); /** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectGanttSchema`. */ const OBJECT_GANTT_NEITHER_CHANNEL = neitherContentChannelGuidance( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b17701487..559e9e56d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2607,6 +2607,9 @@ importers: specifier: 19.2.8 version: 19.2.8(react@19.2.8) devDependencies: + '@object-ui/sdui-parser': + specifier: workspace:* + version: link:../sdui-parser '@object-ui/test-support': specifier: workspace:* version: link:../test-support