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
75 changes: 75 additions & 0 deletions .changeset/7742-kanban-arm-batch70.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
'@object-ui/types': minor
'@object-ui/plugin-kanban': minor
---

**BREAKING (scored `minor` per this repo's version-alignment convention)** — the
`'kanban'` arm retires four accepted spellings and declares one read it never
named (objectui#7742, ADR-0049, maintainer decision batch #70, 2026-09-07:
「同意」).

The accept set moves in **both** directions in one change, which is why the PR
carries `needs:contract-review`.

## Narrowing — four keys the `'kanban'` arm no longer accepts

Each is a `?: never` tombstone on the TypeScript face and a named refusal arm on
the `@object-ui/types/zod` mirror, so a document that names one is **refused by
name** with the remedy in the message. ⛔ None is dropped instead of refused:
`BaseSchema` is `.passthrough()`, so a dropped key is *kept*, not refused — the
failure objectui#7664's own first cut shipped at `onCardClick`.

- **`allowCollapse`, `cardTemplates`, `columnWidths`** — declared on both faces
since the dialect was carried over, and read by **no registered board**.
Re-measured for this change over `packages/plugin-kanban/src`, every file
including tests: 0 hits / 0 files each, with `groupBy` (85 hits / 27 files),
`cardTitle` (18/9) and `coverImageField` (17/3) firing as controls on the same
instrument. An author who wrote `allowCollapse: true` validated green and got
a board that never collapsed off that key. Each capability exists on a
*different* channel, and the refusal message names it: a lane's own
`columns[].collapsed` for collapsing, a `templates` component prop for card
templates, a `useColumnWidths` hook option for widths. Wiring a board-level
switch to any of them would be new behaviour and is not ordered here.
`CardTemplate` and `ColumnWidthConfig` stay exported — the prop and the hook
still consume the types.
- **`titleField`** — ⚠️ **not** an inertness retirement, and reading it as one
gets the mechanism backwards. `ObjectKanban` still reads the key and the read
stays, because the **sibling `object-kanban` arm declares it and keeps it**
(objectui#7322 item ②). What retires is *this* arm's acceptance of the legacy
spelling: one arm, one spelling, and the refusal points at `cardTitle`. A
`type: "object-kanban"` document naming `titleField` still validates and still
renders; a `type: "kanban"` one is now refused. The key was never declared on
this face before — it rode `BaseSchema`'s index signature — so this is the
first time this face judges it at all.

## Widening — one key the board reads and no face named

- **`navigation`** is now declared on `KanbanSchema` (both faces), on the gantt
precedent objectui#5903. `ObjectKanban` reads it to choose the record-detail
overlay mode and defaults it to a drawer; until now an authored overlay mode
rode `BaseSchema`'s `[key: string]: any` — admitted, never examined — and the
read site had to spell itself `(schema as any).navigation`. That cast is gone.
The member list is `@objectstack/spec`'s `NavigationConfig` by reference, not
restated, so the vocabulary cannot fork.

## `@object-ui/plugin-kanban` — `objectFields` moves from the schema bag to a React prop

`objectFields` (the fetched object's field definitions, which card conditional
formatting needs so a rule comparing a relation sees the stored foreign key) is
now a **React prop on `KanbanRendererProps`**, a sibling of `schema`, rather than
a member of the `schema` bag. `ObjectKanban` — the one caller that fetches the
object definition — injects it there. Callers that render `KanbanRenderer`
directly and passed `objectFields` inside `schema` must move it to the prop.

⚠️ **What this closes, stated at the arm level** — measured through the real
`SchemaRenderer`, not assumed. It closes the *schema read path*, and on the
`'kanban'` arm that closes the key outright: `ObjectKanbanRenderer` serves that
type key and discards its rest-spread (`void _props;`), so an authored
`objectFields` on a `type: "kanban"` node reaches nothing. It does **not** make
`objectFields` unreachable to an author in general. On the schema-only
`'kanban-ui'` entry, which `KanbanRenderer` serves directly, the key is absent
from `SchemaRenderer`'s stripped-metadata list, so it survives that renderer's
generic prop spread and arrives on the very prop this change adds — an authored
`objectFields` still reaches `resolveConditionalFormatting` there, as it did
before, and no schema face declares or judges it. Closing that entry is a
separate change and is not made here.
6 changes: 3 additions & 3 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -973,15 +973,15 @@ A static board carries its cards inline:
| `columns` | `KanbanColumn[]` | Lanes, each with `id`, `title`, `cards`, and optional `limit` / `className` / `collapsed`. A card has `id`, `title`, optional `description` and `badges`. |
| `quickAdd` | `boolean` | Show a Quick Add button at the bottom of each column. |
| `coverImageField` | `string` | Field whose URL renders as the card cover image. |
| `allowCollapse` | `boolean` | Allow columns to be collapsed. |
| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. |
| `cardTemplates` | `CardTemplate[]` | Predefined quick-add templates. |
| `columnWidths` | `ColumnWidthConfig` | Column width configuration. |
| `grouping` | `GroupingConfig` | ListView grouping config; its first field is the swimlane fallback. |
| `navigation` | `ViewNavigationConfig` | Record navigation behaviour when a card is clicked (drawer / dialog / page). Defaults to an inline right-side drawer. |
| `onCardMove` | `function` | Runtime slot supplied by a React host, `(cardId, fromColumnId, toColumnId, newIndex)`; not authorable in JSON. |
| `onCardClick` | `function` | Runtime slot supplied by a React host, `(card, event?)`; not authorable in JSON. On the object-bound board the host's handler runs alongside the record-detail overlay. |
| `onQuickAdd` | `function` | Runtime slot supplied by a React host, `(columnId, title)`; not authorable in JSON. |

> Four spellings the `kanban` arm once accepted are now refused by name (objectui#7742, ADR-0049). `allowCollapse`, `cardTemplates` and `columnWidths` were declared and read by no registered board — collapse a lane with `columns[].collapsed`; card templates and column widths reach the board through a component prop and a hook option, not through the node. `titleField` is the legacy spelling of `cardTitle` and is retired on this arm only: write `cardTitle`. An `object-kanban` node still accepts `titleField`.

> The former `@object-ui/types` kanban dialect — `DeclarativeKanbanSchema`, with a board-level `draggable`, a column `color` and card `labels` / `priority` — was retired in objectui#7664: no registered renderer read it, so a board written that way validated and rendered empty. `draggable` and a column `color` are now refused by name; a static board written with `columns[].cards[]` as above is the same document in both dialects and renders every card.

**Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema)
Expand Down
36 changes: 23 additions & 13 deletions packages/plugin-kanban/src/ObjectKanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,10 @@ export const ObjectKanban: React.FC<ObjectKanbanComponentProps> = ({
// CLOSED, not open — do not re-open it as a cleanup. If bucket-vocabulary
// unification ever becomes a product direction that is a fresh ruling,
// with visual-regression evidence across all four surfaces in one stroke.
const navConfig = (schema as any).navigation ?? { mode: 'drawer' };
// `navigation` is DECLARED on `KanbanSchema` since objectui#7742 (gantt
// precedent objectui#5903), so this read is typed rather than cast. It stayed
// `(schema as any)` for exactly as long as no schema face named the key.
const navConfig = schema.navigation ?? { mode: 'drawer' };
// When this kanban is embedded in an ObjectView, the parent provides
// `onRowClick`/`onCardClick` and owns the unified record-detail overlay.
// We must always forward to the parent in that case — otherwise we'd open
Expand Down Expand Up @@ -1143,23 +1146,30 @@ export const ObjectKanban: React.FC<ObjectKanbanComponentProps> = ({

return (
<>
<KanbanRenderer schema={{
...effectiveSchema,
<KanbanRenderer
// Card conditional formatting evaluates against the card record, and
// this fetch expands relations (`buildExpandFields` above) exactly as
// the grid's does. Handing the renderer the object's field types is
// what lets a rule comparing a relation see the stored foreign key
// instead of the expanded record (objectui#3501).
objectFields: objectDef?.fields,
// objectui#8307 — the lane headers count rows that came back, so when
// the fetch saturated its window they must say `77+`, not `77`.
countsAreWindowed,
onCardClick: (card: any, event?: any) => {
navigation.handleClick(card, event);
onCardClick?.(card);
},
onCardMove: handleCardMove,
}} />
//
// A PROP, not a schema key (objectui#7742, decision batch #70): it is an
// internal channel from the one caller that fetched the object
// definition, never an authoring surface. On the schema bag it was
// reachable by an author through `BaseSchema`'s passthrough.
objectFields={objectDef?.fields}
schema={{
...effectiveSchema,
// objectui#8307 — the lane headers count rows that came back, so when
// the fetch saturated its window they must say `77+`, not `77`.
countsAreWindowed,
onCardClick: (card: any, event?: any) => {
navigation.handleClick(card, event);
onCardClick?.(card);
},
onCardMove: handleCardMove,
}}
/>
{pendingMove && (
<RequiredFieldsDialog
open
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* 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.
*/

/**
* `objectFields` is a PROP, no longer a key read off the `schema` bag
* (objectui#7742, ADR-0049, maintainer decision batch #70, 2026-09-07).
*
* ## What moved, and why it was a defect
*
* `objectFields` carries the fetched object's field definitions so that a
* conditional-formatting rule comparing a RELATION field sees the stored
* foreign key rather than the record the board's `$expand` substituted for it
* (objectui#3501). Only `ObjectKanban` can know the answer — it is the one
* entry point that fetches an object definition.
*
* It used to travel INSIDE the `schema` bag. That made it reachable by an
* AUTHOR: `BaseSchema` is `.passthrough()`, `SchemaRenderer` hands the node
* down, and on the schema-only `kanban-ui` entry — which has no object schema
* of its own to overwrite it with — an authored `objectFields` reached
* `resolveConditionalFormatting` verbatim. No schema face declared the key, so
* nothing judged it either: an author could hand the predicate layer a
* fabricated field catalogue and change which cards a rule matched. Batch #70
* ruled it an internal channel; it is now a React PROP, a sibling of `schema`.
*
* ## ⚠️ Scope — what the move closes, and what it does NOT
*
* It closes the SCHEMA READ PATH, which is exactly what the assertions below
* pin: `KanbanRenderer` no longer reads `schema.objectFields`. On the `'kanban'`
* arm that closes the key outright — `ObjectKanbanRenderer` serves that type key
* and discards its rest-spread (`void _props;`), so an authored `objectFields`
* on a `type: 'kanban'` node reaches nothing.
*
* ⛔ It does NOT make `objectFields` author-unreachable in general, and this
* file must not be read as claiming that. `objectFields` is absent from
* `SchemaRenderer`'s stripped-metadata list, so on the schema-only `'kanban-ui'`
* entry an authored `objectFields` survives that renderer's generic prop spread
* and arrives on the very prop asserted below. The tests here render
* `KanbanRenderer` DIRECTLY and so never exercise `SchemaRenderer`: that entry
* is measured but NOT pinned here, and closing it is a separate change.
*
* ## How this file discriminates, and why the fixture is shaped this way
*
* The two channels are told apart by an outcome that ONLY the field catalogue
* can produce. The card's `owner` arrives EXPANDED (`{ _id: 'u1' }`), and the
* rule compares `owner` against the bare id `'u1'`:
*
* - with a catalogue naming `owner` a `lookup`, `toPredicateRecord` collapses
* the expanded value back to `'u1'`, the rule MATCHES, and the card is
* painted;
* - with no catalogue, `owner` stays an object, the rule does NOT match, and
* the card is unpainted.
*
* So "painted" is a positive reading of the channel and "unpainted" is a
* negative one — and the positive case is asserted FIRST, as the firing control
* for the negative. Without it, "the schema key did nothing" would also be
* produced by a fixture that never worked on either channel, which is the
* failure mode this pairing exists to rule out.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { KanbanRenderer } from '../index';

// Pay the board's lazy chunk at import time, not inside a `findBy` budget
// (AGENTS.md 测试纪律). The specifier stays byte-identical to the one in
// `../index` — ESM caches by resolved specifier, so this is what makes that
// module's own `React.lazy` factory resolve immediately.
import '../KanbanImpl';

/** The paint the matching rule applies — a colour no other element uses. */
const PAINT = 'rgb(255, 0, 0)';

const RULE = [{ field: 'owner', operator: 'equals', value: 'u1', backgroundColor: PAINT }];

/** `owner` arrives EXPANDED, the way the board's own `$expand` delivers it. */
const CARD = { id: 'c1', title: 'Painted card', owner: { _id: 'u1', name: 'Ann' } };

/** The catalogue that makes `owner` collapsible. `lookup` is an expandable type. */
const FIELD_CATALOGUE = { owner: { type: 'lookup' } };

const BOARD = {
type: 'kanban',
columns: [{ id: 'todo', title: 'To Do', cards: [CARD] }],
conditionalFormatting: RULE,
} as const;

/** The rendered card's own background, read off the element the title sits in. */
async function paintOfTheCard(): Promise<string> {
const title = await screen.findByText('Painted card');
let el: HTMLElement | null = title;
while (el) {
const bg = el.style?.backgroundColor;
if (bg) return bg;
el = el.parentElement;
}
return '';
}

describe('`objectFields` reaches the predicate layer as a PROP (objectui#7742)', () => {
it('THE CONTROL — passed as a prop, the relation rule matches and the card is painted', async () => {
// ⭐ Asserted first and on purpose. It proves the fixture CAN produce a
// paint, so the negative below is a reading about the channel and not about
// a rule that never matched on any channel.
render(<KanbanRenderer schema={BOARD as never} objectFields={FIELD_CATALOGUE} />);
expect(await paintOfTheCard()).toBe(PAINT);
});
});

describe('`objectFields` is no longer read off the schema bag (objectui#7742)', () => {
it('an AUTHORED `objectFields` inside `schema` does not reach the predicate layer', async () => {
// The same catalogue, the same rule, the same card — written where an
// author can write it. It must now do nothing.
render(
<KanbanRenderer schema={{ ...BOARD, objectFields: FIELD_CATALOGUE } as never} />,
);
expect(await paintOfTheCard()).not.toBe(PAINT);
});

it('and with neither channel supplied the card is unpainted — the paint is not unconditional', async () => {
// The third leg: without it, "unpainted" above could be the board's default
// for every card and would say nothing about `objectFields` at all.
render(<KanbanRenderer schema={BOARD as never} />);
expect(await paintOfTheCard()).not.toBe(PAINT);
});
});
Loading
Loading