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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/7322-object-kanban-component-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,5 @@ boards (`columns` + inline `data`, no fetch) that author no `objectName`, which
`ObjectKanbanSchema` declared required **as of 2026-09-06** — objectui#7780's subject,
and that card made it a record-source presence rule on 2026-09-07; their casts stay,
now carrying the reason and the card number.

⏱ **Dated note, 2026-09-30 (objectui#8652).** 「`navigation` is declared on neither face」 in 「Casts this removes」 above was true when written and is falsified by objectui#8652, which declares `navigation` on `ObjectKanbanSchema` on both faces of `@object-ui/types`, by reference to the spec's `NavigationConfig`. The paragraph is kept as written, and is not restated in the present tense.
13 changes: 13 additions & 0 deletions .changeset/8652-kanban-calendar-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
'@object-ui/plugin-kanban': minor
'@object-ui/plugin-calendar': minor
---

`navigation` is declared on the `object-kanban` and `object-calendar` blocks, by reference to `@objectstack/spec` (objectui#8652). This is the objectui half of the maintainer ruling on that card: the spec declares the key on both element entries from 17.5.0, and objectui now mirrors it.

- `@object-ui/types`: `ObjectKanbanSchema.navigation` and `ObjectCalendarSchema.navigation` are declared on both published faces with the spec's `NavigationConfig` type — the same type the grid, view, gantt and map schemas use. Until now the key was admitted and never examined: `navigation: { mode: 'not-a-mode' }` passed both faces while the spec refused it. It is now refused at `navigation.mode`, and a member the spec does not declare inside the block is refused too. A parse adds no default to the block, so a board or calendar without the key still opens a record in a drawer.
- `@object-ui/plugin-kanban`: the `object-kanban` registration publishes `navigation` in its `inputs`, so the designer offers it.
- `@object-ui/plugin-calendar`: the `object-calendar` and `calendar` registrations publish `navigation` in their `inputs`, and `ObjectCalendar` reads the key without a cast.

What a click does with each member is unchanged. One value has no effect on its own: `page` opens nothing on a board or calendar that no parent view navigates for, and a block written without `mode` resolves to `page`. The published input descriptions say so.
50 changes: 25 additions & 25 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ const owedIdsOf = (ledger: Record<string, string>): string[] =>
const OBJECTUI_11111_LEDGER_CAPS = {
unjudgedBlocks: 4, // objectui#11168: object-gantt, object-map, object-timeline, object-tree
offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name
unpublishedKeys: 5, // objectui#11168: 3 (action:button endpoint + undoable, action:icon endpoint); objectui#8652: 2; objectui#8649: 0 (struck by its landing)
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: 2, // objectui#11168: element:definition-list, object-form
memberPins: 4, // objectui#11168: element:definition-list, element:repeater ×3
} as const;
Expand Down Expand Up @@ -1411,18 +1411,11 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
// `visible` stood here until objectui#11168 slice 1 measured each against its
// renderer through the real `SchemaRenderer` and DECLARED all four — the
// documented exit: a declaration retires its own cover.
...owedEntries(
'object-kanban',
['navigation'],
'objectui#8652',
'A SPEC KEY NOT PUBLISHED: 17.5.0 declares `navigation` on this block; objectui#8652 met its unlock with that release and mirrors it as its ruled second step.',
),
...owedEntries(
'object-calendar',
['navigation'],
'objectui#8652',
'A SPEC KEY NOT PUBLISHED: 17.5.0 declares `navigation` on this block; objectui#8652 met its unlock with that release and mirrors it as its ruled second step.',
),
// objectui#8652's two entries — `navigation` on `object-kanban` and
// `object-calendar` — are STRUCK: that card declared the key on both blocks'
// `inputs` (and on both `@object-ui/types` faces), with a member pin apiece
// in `MEMBER_PINS`, so each entry went stale and `carries no stale
// unpublished-key exemption` would refuse it.
// objectui#8649's nine entries — the field-security triple on `record:details`,
// `record:highlights` and `record:related_list` — are STRUCK: that card
// declared all three keys on the three blocks' `inputs`, with the contract's
Expand Down Expand Up @@ -2673,6 +2666,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx',
pins: 'The members are ObjectQL `$filter` elements, and this renderer adds nothing to that contract and subtracts nothing from it: the authored value reaches `dataSource.find` as `$filter` BY IDENTITY (`toBe`, so a normalising rewrite cannot pass), and the retired `filter.calendar` member spelling yields NO configuration — one authored key read twice with two incompatible meanings is the member-level defect objectui#7711 closed here and objectui#4034 closed on the map. Both member forms are covered (the object form and the array-of-arrays form). The spec side cannot supply this: the `object-calendar` `filter` row is `z.unknown()`, so the wire is the only member contract there is (objectui#7711, registered as a pin by objectui#8176 once objectui#8186 declared the key).',
},
'object-calendar.navigation': {
file: 'packages/plugin-calendar/src/__tests__/calendarNavigationMembers-8652.test.tsx',
pins: 'The members of the spec\'s `NavigationConfigSchema` block the calendar reads, each driven through a real event click on the real calendar and asserted on what the click DID: an ABSENT key opens a drawer (the renderer\'s own default, the lit control), `none` and `preventNavigation` open nothing with the flag outranking an overlay mode, `new_window` and `openNewTab` open the record page in a new tab with `openNewTab` outranking `page`, and `size` / `width` are one width decision (`width` wins, a bucket resolves through the size table, `auto` lands on the default). The precedence against a parent view is pinned both ways: an OVERLAY mode keeps the click from `onRowClick` / `onEventClick`, any other mode hands it to them. Two further rows pin the description\'s `page` warning — `page`, and a block without `mode`, open nothing on a calendar no parent view navigates for (objectui#8652).',
},
'object-calendar.sort': {
file: 'packages/plugin-calendar/src/__tests__/ObjectCalendar.sortMembersReachTheWire-8171.test.tsx',
pins: 'The members are `{ field, order }` and those two keys are the whole of what this renderer reads inside one: `{ field, order }` lowers to the `field -> direction` map on `$orderby`, an omitted `order` is ascending rather than a dropped member (the objectui#4022 regression), every member arrives in authored order, and a member with no usable `field` is dropped rather than given an invented one — with an unauthored `sort` reaching the wire as `undefined` as the control. Also carries objectui#7711\'s case transposed: a sort on a field named `calendar` stays a sort and the config still comes from the declared `calendar` container. ⛔ NOT an identity pin, unlike the two `filter` entries: `ObjectCalendar.tsx` writes `$orderby: convertSortToQueryParams(schema.sort)`, which builds a new map, so `toBe` is false about this key — the pin asserts what is read inside the member instead, which is the sharper claim. The spec side cannot supply any of it: the `sort` row is unconstrained (an array, a bare string and a bare number all parse), so the wire is the whole member contract (objectui#8171).',
Expand Down Expand Up @@ -2849,6 +2846,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx',
pins: 'ONE nested position and no more: `schema.grouping?.fields?.[0]?.field` is the FALLBACK source of `swimlaneField`, and that is the entire member contract this board carries for the key. Three rows make it a reading rather than a claim — the swimlane layout appears keyed by `fields[0].field` where without the key there is none; an explicit `swimlaneField` WINS over it; and a second `fields` entry changes nothing, which is what pins the read at `[0]` rather than at "the fields list". The declared description says the rest is inert precisely so the declaration does not recommend a write the renderer cannot honour — this file is what keeps that sentence true. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).',
},
'object-kanban.navigation': {
file: 'packages/plugin-kanban/src/__tests__/kanbanNavigationMembers-8652.test.tsx',
pins: 'The members of the spec\'s `NavigationConfigSchema` block the board reads, each driven through a real card click on the real board and asserted on what the click DID: an ABSENT key opens a drawer (the renderer\'s own default, the lit control), `none` and `preventNavigation` open nothing with the flag outranking an overlay mode, `new_window` and `openNewTab` open the record page in a new tab with `openNewTab` outranking `page`, and `size` / `width` are one width decision (`width` wins, a bucket resolves through the size table, `auto` lands on the default). A parent view\'s click handler outranks the whole key, overlay mode included. Two further rows pin the description\'s `page` warning — `page`, and a block without `mode`, open nothing on a board no parent view navigates for (objectui#8652).',
},
'object-master-detail-form.cancelText': {
file: 'packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx',
pins: 'The I18nLabel object arm, resolved at the read site: through the real `SchemaRenderer`, a `properties.cancelText` map under a `zh` UI renders the map\'s `zh-CN` entry on the Cancel button, with `en` listed FIRST so an `en` or first-entry fallback fails the row. Before objectui#10935 the map was a raw Button child and threw "Objects are not valid as a React child". The button renders only when the host supplies `onCancel`, so the row adds one beside the document and asserts the host handler still runs. A plain-string control and the nothing-authored control (`Cancel`) stay green, and the first row asserts the mounted documents pass `safeValidateSchema`. The manifest half (a map is not a `type-mismatch`) is `masterDetailFormI18nLabelManifest.test.ts` in this directory (objectui#10935).',
Expand Down Expand Up @@ -4638,9 +4639,8 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
// direction — greening a fresh divergence on these two blocks by writing a
// nineteenth entry instead of declaring the input.
//
// TWO is the BOOKED backlog today — objectui#8652's `navigation` pair,
// ruled at the 17.5.0 bump (the last row of the table below) — and ZERO
// is what it measured before that bump, not a budget. `object-calendar`
// ZERO again since objectui#8652 declared the `navigation` pair the 17.5.0
// bump had BOOKED to it (the last two rows of the table below). `object-calendar`
// publishes all nine keys its spec row declares; `object-kanban` publishes
// thirteen of fourteen, and the fourteenth is RULED rather than owed —
// objectui#8313 emptied the board, objectui#8314 emptied the calendar, and
Expand Down Expand Up @@ -4676,6 +4676,9 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
// The only step UP this ceiling has taken, and it is a ruling, not
// headroom: the cap is exactly the two booked entries, and
// objectui#8652's landing takes it back to 0.
// 0 objectui#8652 lands: `navigation` is declared on both blocks'
// `inputs`, each with the `MEMBER_PINS` entry objectui#8212 made
// part of the same obligation — the booked pair's paired exit.
//
// ⚠️ THE LAST STEP IS THE ONLY ONE THAT IS NOT A DECLARATION, and reading
// it as one would be the wrong lesson. Every step above was this ceiling's
Expand Down Expand Up @@ -4729,20 +4732,17 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
'a new unpublished-key exemption was added on a block objectui#8176 newly ' +
'judged — declare the input at its registration site instead; the ' +
'backlog list is shrink-only',
).toBeLessThanOrEqual(2);
).toBeLessThanOrEqual(0);
// Lower it here when the owning cards land, so the ceiling keeps ratcheting
// rather than banking the headroom their fixes free up.
expect(
backlog.length,
'the objectui#8176 backlog shrank — lower the ceiling above to match, in ' +
'the same change that declared the input',
).toBe(2);
// …and the two it holds are exactly objectui#8652's booked `navigation`
// pair, so the ruling's room cannot be spent on a different key.
expect(backlog.sort(), 'the backlog is not the two booked navigation keys').toEqual([
'object-calendar.navigation',
'object-kanban.navigation',
]);
).toBe(0);
// …and it is empty BY NAME, so the room objectui#8652's landing freed
// cannot be spent on a different key.
expect(backlog.sort(), 'the backlog is not empty').toEqual([]);
});

it('the one ruled carve-out on a newly judged block is real, and names its exit', () => {
Expand Down Expand Up @@ -5598,6 +5598,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length,
]),
),
).toEqual({ 'objectui#11168': 13, 'objectui#8652': 2, 'objectui#8649': 0 });
).toEqual({ 'objectui#11168': 13, 'objectui#8652': 0, 'objectui#8649': 0 });
});
});
3 changes: 2 additions & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t
| `limit` | `number` | Fetch window for the board (default 100). |
| `coverImageField` | `string` | Field whose URL renders as the card cover image. |
| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. |
| `navigation` | `ViewNavigationConfig` | What a card click opens — the spec's `NavigationConfig` by reference, the type `ObjectGridSchema.navigation` uses: `mode` (`page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none`) with `size`, `openNewTab` and `preventNavigation`. With the key absent a click opens the record in a drawer. ⚠️ `page` — and a block written without `mode`, which takes the spec's `page` default — opens nothing on a board no parent view navigates for (objectui#11293). |

> `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`.

Expand All @@ -987,7 +988,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t
>
> ⚠️ The **bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy` — so on a board that *does* declare one the strings are ignored and the lanes come from the group field's picklist options or from the data. Since objectui#8990 made `groupBy` optional, a lane-less board is a valid authoring and this arm is live on it: the lanes are drawn, titled by the **raw strings** (a grouped board titles its lanes with the picklist *labels* instead). ⚠️ Such a board holds **no cards** — with no lane key the records are never distributed — and dragging a card writes nothing back. It is lane headings, not a populated board; to control the lanes of a working board, declare `groupBy` and write the `{ id, title }` array.
>
> The other keys the retired `kanban` arm alone declared — `cardTitle`, `swimlaneField`, `grouping` and `navigation` — are still undeclared on this face. The renderer reads them, so a board may carry them; they are simply not judged.
> Of the other keys the retired `kanban` arm alone declared, `cardTitle` (objectui#9606) and `navigation` (objectui#8652) are declared on this face; `swimlaneField` and `grouping` are still undeclared. The renderer reads those two, so a board may carry them; they are simply not judged.

> **Handler keys are not authorable in JSON, and all three now say so by name.** Since objectui#7804 this face declares `onCardClick` as an objectui#6124 **runtime slot**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⭐ The other two keys are **tombstones**, not runtime slots, so their TypeScript twins are `?: never` rather than callable. `onCardMove` has been one since objectui#9342: an authored one reached **nothing** even as a function, because an object-bound board substitutes its own mover, and the mover lives on `KanbanRenderer`'s React prop of the same name, a sibling of its `schema`. `onQuickAdd` has been one since objectui#11234: a supplied one reached the board and was never called, because its partner `quickAdd` is retired here. `ObjectKanban` renders an internal board that takes the Quick Add pair only as explicit props and supplies neither half; the pair lives on `KanbanRenderer`'s `schema`, for a React host.

Expand Down
11 changes: 11 additions & 0 deletions content/docs/plugins/plugin-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const schema: ObjectCalendarSchema = {
calendar?: CalendarConfig, // Calendar-specific configuration
filter?: ViewFilterRule[], // Query filter, lowered to $filter
sort?: SortConfig[], // Query sort, lowered to $orderby
navigation?: ViewNavigationConfig, // What an event click opens (see below)
onEventClick?: (record: any) => void,
onDateClick?: (date: Date) => void,
className?: string
Expand All @@ -267,6 +268,16 @@ footnote. Inline records reach the calendar as the in-memory adapter's own deep
copy, so they must be JSON-serializable and a record handed to `onEventClick` is
not `===` the authored object.

`navigation` is what an event click opens — the spec's `NavigationConfig` by
reference, `ViewNavigationConfig` in `@object-ui/types`, the type
`ObjectGridSchema.navigation` uses: `mode` (`page`, `drawer`, `modal`, `split`,
`popover`, `new_window` or `none`) with `size`, `openNewTab` and
`preventNavigation`. With the key absent a click opens the record in a drawer.
An overlay mode keeps the click from a parent view's `onRowClick` /
`onEventClick`; any other mode hands it to them. ⚠️ `page` — and a block written
without `mode`, which takes the spec's `page` default — opens nothing on a
calendar no parent view navigates for (objectui#11293).

### CalendarConfig

```plaintext
Expand Down
1 change: 1 addition & 0 deletions content/docs/plugins/plugin-kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ interface KanbanCard {
| `columns` | `string[] \| KanbanLane[]` | Swimlane definitions — an array of `{ id, title }` lanes (one per `groupBy` value), **or** an array of bare value strings; never a mix. NOT a field projection. The bare-string array is accepted for spec parity but is ignored on this block (see the note above). |
| `filter` | ViewFilterRule[] | Query filter for an object-driven board, lowered to `$filter` |
| `limit` | number | Rows fetched by an object-driven board (default 100) |
| `navigation` | `ViewNavigationConfig` | What a card click opens — the spec's `NavigationConfig` by reference, the type `ObjectGridSchema.navigation` uses: `mode` (`page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none`) with `size`, `openNewTab` and `preventNavigation`. With the key absent a click opens the record in a drawer, and a click handler from a parent view outranks the whole key. ⚠️ `page` — and a block written without `mode`, which takes the spec's `page` default — opens nothing on a board no parent view navigates for (objectui#11293). |
| `className` | string | Additional Tailwind CSS classes |

### Lane Properties
Expand Down
Loading
Loading