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
14 changes: 14 additions & 0 deletions .changeset/10946-expression-wire-slots-by-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,17 @@ So the sentences above that say the bulk def qualifies no record, and that the
test file pins the `ast`-only fault "on both", describe the tree at
objectui#10946, not the code in this release. See objectui#11322's changesets
for `@object-ui/plugin-grid` and `@object-ui/types`.

⚠️ **Dated note, 2026-10-03 — the kanban rule is no longer a union — objectui#11522.**
At this change the kanban board's rule was a union of two dialects (the native
`{ field, operator, value }` comparison and `{ condition, style }`), and the
sentence above that says "the list view's and the kanban board's rule unions
share one `condition` schema" describes that union. Now the kanban rule is ONE
object, the spec list view's `{ condition, style }` rule by reference, and the
native and flat-colour dialects are refused by name on `object-kanban`. It still
reads the same `condition` schema, so everything this entry says about the
condition (the `z.string()` first arm, the envelope, the `''` control, the
reference identity of the spec arm) still holds on `object-kanban`, and
`spec-expression-wire-slots-10946.test.ts` still pins it there, now reading
`condition` straight off the rule's shape. The rest of this entry is kept as
the reading of this change.
30 changes: 30 additions & 0 deletions .changeset/11522-kanban-rule-dialect-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/types': minor
'@object-ui/plugin-kanban': minor
---

**BREAKING for authors of `object-kanban` card rules, released as `minor`.** `object-kanban`'s `conditionalFormatting` takes ONE rule dialect, the spec list view's `{ condition, style }`: a CEL `condition` over the card's `record.*` and a CSS `style` map. The two other dialects it used to take are retired with no alias window and refused by name (objectui#11522).

| Rule on `object-kanban` | Before | Now |
|---|---|---|
| `{ condition, style }` | accepted | accepted, unchanged |
| native `{ field, operator, value, backgroundColor?, borderColor? }` | accepted on every face | refused at `field`, `operator`, `value` and the colour key |
| flat CEL `{ condition, backgroundColor }` (a colour beside `condition`, no `style`) | refused as a bare union failure | refused at the colour key, naming the retirement |
| `{ condition, style, backgroundColor }` | accepted by `safeValidateSchema` (the colour key was stripped from the parse, then painted by the board anyway) | refused at the colour key |
| `{ condition, style, label }` (any other undeclared key) | accepted by `safeValidateSchema` | refused as an unrecognized key, with the spec rule's own message |

Each refusal message names the key, objectui#11522 and the `{ condition, style }` respelling, on `ObjectKanbanSchema`, `safeValidateSchema` and `StrictAnyComponentSchema` alike.

**Respelling.** `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. `not_equals` is `!=`, `contains` is `record.f.contains(…)` and `in` is `record.f in [ … ]`. A top-level colour moves into `style`; `textColor` is `style.color`.

**`@object-ui/types`.**

- `KanbanConditionalFormattingRule` is now an interface that extends `SpecConditionalFormattingRule`, with `field`, `operator`, `value`, `backgroundColor`, `borderColor` and `textColor` declared `?: never`. It used to be the union of `SpecConditionalFormattingRule` and `KanbanNativeConditionalFormattingRule`.
- `KanbanNativeConditionalFormattingRule` is removed. Importing it is a compile error (TS2305).
- `KanbanConditionalFormattingRuleSchema`, the rule schema `ObjectKanbanSchema` applies, is a module export of this package's `src/zod/objectql.zod.ts`. It is not on the `@object-ui/types/zod` barrel or on any other entry of the package's `exports` map, so it is not an import a consumer can name. It is the spec `ListViewSchema.conditionalFormatting` rule taken by reference and extended, not a union. It keeps the spec rule's strictness and its `style` map. Its `condition` is the same schema the list view's and the grid's `{ condition, style }` arm reads, so a string condition is still not canonicalized into an envelope and `''` is still accepted. The six retired keys are retirement tombstones.

**`@object-ui/plugin-kanban`.** The `object-kanban` registration's `conditionalFormatting` input description now describes the one rule. `KanbanRendererProps.schema.conditionalFormatting` and the board's `ConditionalFormattingRule` follow the narrowed type.

**Not changed: what the board paints.** The shared evaluator, `resolveConditionalFormatting` in `@object-ui/core`, keeps every arm, because the grid's and the list view's rule union still declares them. A `{ condition, style }` rule styles a card exactly as before. A rule that a relay hands the board, for example a list view's, is painted as before too. Only the authored `object-kanban` member narrowed.

Pins: `packages/types/src/__tests__/kanban-conditional-formatting.test.ts` (turned around) pins both refusals on all three zod faces, the spec rule's identity and strictness, and the TS face. `ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx` and `objectFieldsIsAPropNotASchemaKey-7742.test.tsx` in `@object-ui/plugin-kanban` draw the respelled rules through the real board and assert the same cards are painted.
17 changes: 17 additions & 0 deletions .changeset/7664-kanban-arm-plugin-dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,20 @@ This is a breaking change shipped as `minor`: this repository's
version-alignment rule keeps objectui's major pinned to `@objectstack`'s and
ships objectui's own breaking changes as `minor` with the break spelled out in
the changeset body, which is what the bullets above are.

⚠️ **Dated note, 2026-10-03 — `KanbanConditionalFormattingRuleSchema` is no longer a union — objectui#11522.**
At this change `KanbanConditionalFormattingRuleSchema` was "the rule union the
`'object-kanban'` arm already applied": the native `{ field, operator, value }`
comparison or `{ condition, style }`. Now it is one object, the spec list view's
`{ condition, style }` rule by reference, and the native rule and a top-level
colour key (`backgroundColor`, `borderColor`, `textColor`) are refused by name;
the `'kanban'` arm it was shared with has itself retired in this release
(objectui#8802). It keeps its name, and it is a module export of
`src/zod/objectql.zod.ts` inside `@object-ui/types`, NOT an export of the
`@object-ui/types/zod` barrel. Measured at objectui#11522's change: that barrel
re-exports `KanbanCardSchema`, `KanbanColumnSchema` and `ObjectKanbanSchema` from
the kanban family and not this schema, and no entry of the package's `exports`
map carries it. So the bullet above that calls it "newly exported from
`@object-ui/types/zod`" does not hold in this release either; whether it held at
objectui#7664's own commit was not measured. The rest of this entry is kept as
the reading of this change.
Original file line number Diff line number Diff line change
Expand Up @@ -2878,7 +2878,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'object-kanban.conditionalFormatting': {
file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx',
pins: 'Members are card STYLE RULES in two accepted dialects, and BOTH reach the sink: the native `{ field, operator, value, backgroundColor }` and the spec CEL `{ condition, backgroundColor }` each colour the matching card and only it, with the sibling card in the same render as the live non-matching control so a green cannot come from two unstyled cards agreeing. ⛔ NOT an identity pin and not a wire pin: `ObjectKanban.tsx` never names this key at all (measured zero, against nine for `cardFields` in the same file) — it rides the `{ ...schema }` spread into `KanbanRenderer`, which forwards it to `KanbanImpl`\'s `getCardStyles`. That makes the pin load-bearing in a way the others are not: an edit replacing that spread with an explicit key list drops the key silently and nothing else in the repo would notice. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).',
pins: 'Members are card STYLE RULES in ONE dialect since objectui#11522, the spec list view\'s `{ condition, style }`, and it reaches the sink: a rule colours the matching card and only it, a second rule aimed at the other card does the same, and the whole `style` map arrives, with the sibling card in the same render as the live non-matching control so a green cannot come from two unstyled cards agreeing. The retired native `{ field, operator, value }` and flat-colour dialects are refused by name on the types side (`packages/types/src/__tests__/kanban-conditional-formatting.test.ts`); these rows are their respellings, painting the same cards. ⛔ NOT an identity pin and not a wire pin: `ObjectKanban.tsx` never names this key at all (measured zero, against nine for `cardFields` in the same file) — it rides the `{ ...schema }` spread into `KanbanRenderer`, which forwards it to `KanbanImpl`\'s `getCardStyles`. That makes the pin load-bearing in a way the others are not: an edit replacing that spread with an explicit key list drops the key silently and nothing else in the repo would notice. The spec row is `z.unknown()`, so the read site is the whole member contract (objectui#8313).',
},
'object-kanban.data': {
file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx',
Expand Down
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t
| `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. |
| `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 }`. |
| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules, each `{ condition, style }` — a CEL `condition` over the card's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the card. The native `{ field, operator, value }` rule and a colour written beside `condition` (rather than inside `style`) are retired and refused by name (objectui#11522): `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. |
| `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 the record page through the record navigator the host publishes (objectui#11293); the console publishes one on its custom pages, record pages and list views, and under a host that publishes none the click opens nothing. |

> `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`.
Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-kanban/src/KanbanImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ const SWIMLANE_AXIS_X_PADDING = 'px-2 pl-36 sm:pl-44'
// was for any importer. A re-export is not a second declaration.
export type { KanbanCard, KanbanColumn } from './types'

// Card formatting accepts the native `{ field, operator, value }` shape and the
// spec `{ condition, style }` CEL shape (issue #1584) — see @object-ui/types.
// Card formatting is the spec `{ condition, style }` CEL rule — the one dialect
// `object-kanban` declares since objectui#11522 — see @object-ui/types.
export type ConditionalFormattingRule = KanbanConditionalFormattingRule

export interface KanbanBoardProps {
Expand Down Expand Up @@ -157,10 +157,12 @@ export interface KanbanBoardProps {
* Evaluate conditional formatting rules for a card.
* Returns CSS style overrides for backgroundColor and borderColor.
*/
// Card conditional formatting now delegates to the shared CEL evaluator
// Card conditional formatting delegates to the shared CEL evaluator
// (issue #1584 / ADR-0058) so kanban cards, list rows, and grid rows reach the
// identical verdict. Beyond the native `{ field, operator, value }` rules the
// kanban schema declares, this also accepts spec `{ condition, style }` rules.
// identical verdict. The kanban schema declares the spec `{ condition, style }`
// rule only (objectui#11522); the evaluator is shared with the grid and the list
// view, whose rule union still declares the native and colour-key arms, so it
// still reads them — a rule a relay hands this board is painted as it was.
// The host predicate scope is bound alongside the card so `features.*` /
// `current_user.*` conditions resolve here exactly as they do on grid rows.
function getCardStyles(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@
* - `grouping` — WHICH SINGLE NESTED POSITION is read, and under what
* precedence. One position (`fields[0].field`), one role (the fallback for
* `swimlaneField`), everything else inert.
* - `conditionalFormatting` — WHICH MEMBER DIALECTS are evaluated, and
* against what. Two dialects, per card, on the card's own record.
* - `conditionalFormatting` — WHICH MEMBER DIALECT is evaluated, and
* against what. One dialect since objectui#11522, `{ condition, style }`,
* per card, on the card's own record.
*
* ## The spec supplies none of it
*
Expand Down Expand Up @@ -447,21 +448,29 @@ describe('objectui#8313 — `object-kanban`.`grouping`: one nested position and
});

/* -------------------------------------------------------------------------- */
/* `conditionalFormatting` — two member dialects, per card. */
/* `conditionalFormatting` — the `{ condition, style }` member, per card. */
/* -------------------------------------------------------------------------- */

describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: two member dialects', () => {
describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: the `{ condition, style }` member', () => {
const ROWS = [
{ id: 'a', name: 'Alpha deal', status: 'open', owner: 'ann' },
{ id: 'b', name: 'Beta deal', status: 'open', owner: 'bob' },
];

it('the NATIVE `{ field, operator, value }` member colours the matching card, and only it', async () => {
// ⚠️ RESPELLED, not rewritten (objectui#11522). The two rows below used to
// author the two dialects the member then accepted: the native
// `{ field: 'owner', operator: 'equals', value: 'ann', backgroundColor }` and
// the flat CEL `{ condition: "record.owner == 'bob'", backgroundColor }`.
// Both are retired and refused by name on every zod face; each row now writes
// the SAME predicate and the SAME paint as `{ condition, style }`, and asserts
// the SAME card is painted and its neighbour is not — so a respelling that
// changed which card the rule reaches would turn the row red.
it('a `{ condition, style }` member colours the matching card, and only it', async () => {
const adapter = makeAdapter();
const { container } = renderBoard(adapter, {
data: ROWS,
conditionalFormatting: [
{ field: 'owner', operator: 'equals', value: 'ann', backgroundColor: 'rgb(1, 2, 3)' },
{ condition: "record.owner == 'ann'", style: { backgroundColor: 'rgb(1, 2, 3)' } },
],
});

Expand All @@ -474,14 +483,14 @@ describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: two member
expect(stylesOnCard(container, 'Beta deal')).not.toContain('background-color');
});

it('the SPEC CEL `{ condition }` member does the same, against the card’s own record', async () => {
// The second dialect (#1584 / ADR-0058). Aimed at the OTHER card on
// purpose, so a shared fixture cannot make the two dialects look alike.
it('a second rule aimed at the OTHER card does the same, against that card’s own record', async () => {
// Aimed at the other card on purpose, so one shared fixture cannot make two
// rules look alike (#1584 / ADR-0058).
const adapter = makeAdapter();
const { container } = renderBoard(adapter, {
data: ROWS,
conditionalFormatting: [
{ condition: "record.owner == 'bob'", backgroundColor: 'rgb(4, 5, 6)' },
{ condition: "record.owner == 'bob'", style: { backgroundColor: 'rgb(4, 5, 6)' } },
],
});

Expand All @@ -491,6 +500,23 @@ describe('objectui#8313 — `object-kanban`.`conditionalFormatting`: two member
expect(stylesOnCard(container, 'Alpha deal')).not.toContain('background-color');
});

it('the WHOLE `style` map reaches the card — `style` is the only colour channel now', async () => {
// With the top-level colour keys retired (objectui#11522), every paint
// rides `style`, so the map must arrive whole and not as one picked key.
const adapter = makeAdapter();
const { container } = renderBoard(adapter, {
data: ROWS,
conditionalFormatting: [
{ condition: "record.owner == 'ann'", style: { backgroundColor: 'rgb(1, 2, 3)', borderColor: 'rgb(7, 8, 9)' } },
],
});

await waitFor(() => expect(container.textContent).toContain('Alpha deal'));
expect(stylesOnCard(container, 'Alpha deal')).toContain('background-color: rgb(1, 2, 3)');
expect(stylesOnCard(container, 'Alpha deal')).toContain('border-color: rgb(7, 8, 9)');
expect(stylesOnCard(container, 'Beta deal')).not.toContain('border-color');
});

it('⚠️ `ObjectKanban.tsx` never NAMES this key — it rides the `{ ...schema }` spread', () => {
// The structural fact that makes the two rows above load-bearing, made
// mechanical instead of left in prose. `conditionalFormatting` is the only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ 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 }];
// `{ condition, style }`: RESPELLED (objectui#11522) from the retired native
// `{ field: 'owner', operator: 'equals', value: 'u1', backgroundColor }` — the
// same comparison on the same relation field, the same paint.
const RULE = [{ condition: "record.owner == 'u1'", style: { 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' } };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ const DECLARED_STRUCTURED_KEYS = [
{
key: 'conditionalFormatting',
arm: 'array',
value: [{ field: 'owner', operator: 'equals', value: 'ann', backgroundColor: '#eef' }],
// `{ condition, style }`: respelled (objectui#11522) from the retired native
// `{ field: 'owner', operator: 'equals', value: 'ann', backgroundColor }`.
value: [{ condition: "record.owner == 'ann'", style: { backgroundColor: '#eef' } }],
},
] as const;

Expand Down
Loading
Loading