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
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an `object-kanban` page block's `conditionalFormatting` takes the list view's own `[{ condition, style }]` rules instead of any value (#21464)

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-object-kanban-conditional-formatting-typed -->

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.

**`@objectstack/spec`**

- **`object-kanban` `conditionalFormatting` is the list view's own member, by reference, as on `object-grid`.** It was `z.unknown()`, held while objectui's kanban also authored a native `{ field, operator, value, backgroundColor }` rule the list view refuses. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the evaluator the grid's rows use. So `42`, a bare string, a single rule outside a list or a rule with no `style` — values that passed and painted no card — are refused, and so are a blank `condition`, a non-string `style` value, the native rule, an `expression` rule and a colour written beside `condition` or `style`.
- **`ObjectKanbanProps`** carries the list view's rule type on `conditionalFormatting` instead of `unknown`. The member's string `condition` parses to the `{ dialect: 'cel', source }` envelope, exactly as it does on a list view and on `object-grid`.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `conditionalFormatting: [{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }]` | `conditionalFormatting: [{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }]` (`not_equals` is `!=`, `contains` is `.contains(…)`, `in` is `record.FIELD in [ … ]`) |
| `conditionalFormatting: [{ condition: "record.priority == 'high'", backgroundColor: '#fee2e2' }]` | `[{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }]` — every colour goes in `style` |
| `conditionalFormatting: [{ expression: "record.priority == 'high'", style: { color: 'red' } }]` | `[{ condition: "record.priority == 'high'", style: { color: 'red' } }]` |
| `conditionalFormatting: { condition, style }` (one rule, no list) | `conditionalFormatting: [{ condition, style }]` |
| `conditionalFormatting: [{ condition: '', style }]` | delete the rule — a blank condition matches no card |

The one-line fix: write each rule as `{ condition, style }`, a CEL `condition` over the card's `record.*` and a CSS `style` map, the rule a list view declares. No conversion is registered: nothing on the load path refuses the shape, and the census below found no working rule to respell — the D3 entry `ui-object-kanban-conditional-formatting-typed` carries that judgment.

## Who is affected, measured

A writer is a value written on the block: a page-component node (an object literal naming `object-kanban`, flat or in its `properties` bag, or a literal asserted as one), a direct parse through the row, the block's React component inside `schema={{…}}`, or the argument of a local test helper that mounts one. Values resolve through same-file constants and spreads, and parameters at every same-file call site. Each static value was parsed through the list view's member; a second pass parsed every rule-shaped object within 400 characters after a `conditionalFormatting` token, in any syntax, and each remaining hit was read by hand.

- **objectstack** at `16d241a6af`, every tracked file: one writer, this package's own test that the key survived the `quickAdd` retirement, `[{ field: 'priority', value: 'high' }]` — no `operator`, so the board's evaluator built no predicate from it and painted nothing. Respelled to a `{ condition, style }` rule in the same change.
- **objectui** at the `.objectui-sha` pin `ab1879721595` and at `main` `2e818d0b51` (the readers are byte-identical between the two), every value a test fixture: nine `{ condition, style }` writers on the block (three through the board test's mount helper, one asserted node, one declared-keys row parsed through this very row, one live-member row, the dialect test's control, and the wire-slot test's string and envelope conditions), all parse. The ten refused values are refusal probes. Nine are refused by objectui's own faces too: the native rule, the flat colour rule, a colour beside `style` (three keys), an undeclared `label` and three malformed conditions. The tenth is objectui's probe that its mirror still admits a blank `condition`, which the board answers with no paint. Eight more rules mount the runtime `KanbanBoard` directly rather than the block, and the view-face relays carry a list view's rules; all of them parse.
- **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no `conditionalFormatting` at all (controls: `kanban` in 48 and 35 files).
- **Deployed metadata** was not measured.
9 changes: 8 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ Sort field and direction pair
| **grouping** | `{ fields: object[] }` | optional | View grouping config; its first field is the swimlane fallback |
| **quickAdd** | `never` | optional | [REMOVED] `object-kanban` property `quickAdd` was removed in @objectstack/spec 17 (ADR-0049) — the board forwarded it, but the per-column affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied function JSON cannot carry and no producer ever put on an `object-kanban` node, so authoring it was a parse-clean no-op. Delete the key; `object-kanban` offers no quick-add control. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **coverImageField** | `string` | optional | Image field rendered as the card cover |
| **conditionalFormatting** | `any` | optional | Card conditional formatting rules |
| **conditionalFormatting** | `{ condition: string \| object; style: Record<string, string> }[]` | optional | Card conditional formatting rules — `[{ condition, style }]`, the same rules a list view declares: the first rule whose CEL `condition` holds applies its CSS `style` map to that card |
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Card-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed board carries the key only when the author wrote it |

### Nested Shape: `ObjectKanbanProps.columns[number]`
Expand Down Expand Up @@ -974,6 +974,13 @@ View filter rule
| :--- | :--- | :--- | :--- |
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |

### Nested Shape: `ObjectKanbanProps.conditionalFormatting[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL) to evaluate. |
| **style** | `Record<string, string>` | ✅ | CSS styles to apply when condition is true |

### Nested Shape: `ObjectKanbanProps.navigation`

| Property | Type | Required | Description |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #21464 — the `object-kanban` page block's `conditionalFormatting` was
// `z.unknown()`, held while objectui's kanban authored a second rule dialect
// the list view's member refuses. objectstack-ai/objectui#11522 retired that
// dialect from objectui's authoring faces, so the row now takes the list view's
// own member, by reference, as `object-grid` does. D3 only: page-component
// `properties` is not parsed on the metadata save or load path, so a stored page
// is never refused; and the authored census found no working rule to respell —
// the refused values are objectui's own refusal probes and one no-predicate
// fixture in this package, respelled in the same change.
export const entry: SemanticMigration = {
id: 'ui-object-kanban-conditional-formatting-typed',
surface: 'page `object-kanban` components — `properties.conditionalFormatting` (which used to accept any '
+ 'value)',
replacement: 'the list view\'s own rules, `[{ condition, style }]`: a non-blank CEL `condition` over the '
+ 'card\'s `record.*` and a CSS `style` map of string values. Rewrite a native rule `{ field, operator, '
+ 'value, backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`, an '
+ '`expression` as `condition`, and move a colour written beside `condition` into `style`.',
reason: 'The board reads `conditionalFormatting` as an ordered list of `{ condition, style }` rules, through '
+ 'the evaluator the grid\'s rows use, and paints a card with the `style` of the first rule whose condition '
+ 'holds; objectui declares exactly the list view\'s rule as the member\'s only dialect. The page-component '
+ 'row declared it `z.unknown()`, so `42`, a bare string or a rule with no `style` passed the '
+ 'component-props gate and the board painted no card for it. The row now takes the list view\'s own '
+ 'member, by reference, as `object-grid` does, so one rule is judged the same way on every door. It is '
+ 'read where every page component\'s props are: the component-props gate reports a refused value as an '
+ 'advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, '
+ '`objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page '
+ 'component\'s `properties` is not parsed on the metadata save or load path. No conversion is '
+ 'registered: nothing on the load path refuses the shape, and the authored census found no working rule '
+ 'to respell. Deployed metadata NOT MEASURED.',
acceptanceCriteria: 'Every `object-kanban` node validates: `objectstack validate` reports no '
+ '`component-props-invalid` / `component-props-unknown-key` finding under '
+ '`properties.conditionalFormatting`. Each board that sets rules paints the card each rule names with '
+ 'its `style`.',
};
47 changes: 47 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6318,6 +6318,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
+ 'the component-props gate (advisory); a stored page still saves and loads, so no conversion '
+ 'is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`.',
},
{
id: 'ui-object-kanban-conditional-formatting-typed',
order: 77,
text:
'It also types the `object-kanban` page block\'s `conditionalFormatting`, the one member the '
+ '`ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui\'s '
+ 'kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` '
+ 'passed every door and the board painted no card for it. objectui has since made the list view\'s '
+ '`{ condition, style }` rule the member\'s only authoring dialect, and the board evaluates it with the '
+ 'grid\'s evaluator, so the row takes the list view\'s own member by reference, as `object-grid` does. '
+ 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is '
+ 'registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`.',
},
{
id: 'ui-object-map-gantt-tree-navigation-typed',
order: 64,
Expand Down Expand Up @@ -20193,6 +20206,40 @@ const step18: MigrationStep = {
+ 'its `colors` map names, the navigation mode on a row click, the conditional styles, the bulk '
+ 'actions, the group-header numbers and the affordances `operations` names.',
},
// #21464 — the `object-kanban` page block's `conditionalFormatting` was
// `z.unknown()`, held while objectui's kanban authored a second rule dialect
// the list view's member refuses. objectstack-ai/objectui#11522 retired that
// dialect from objectui's authoring faces, so the row now takes the list view's
// own member, by reference, as `object-grid` does. D3 only: page-component
// `properties` is not parsed on the metadata save or load path, so a stored page
// is never refused; and the authored census found no working rule to respell —
// the refused values are objectui's own refusal probes and one no-predicate
// fixture in this package, respelled in the same change.
{
id: 'ui-object-kanban-conditional-formatting-typed',
surface: 'page `object-kanban` components — `properties.conditionalFormatting` (which used to accept any '
+ 'value)',
replacement: 'the list view\'s own rules, `[{ condition, style }]`: a non-blank CEL `condition` over the '
+ 'card\'s `record.*` and a CSS `style` map of string values. Rewrite a native rule `{ field, operator, '
+ 'value, backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`, an '
+ '`expression` as `condition`, and move a colour written beside `condition` into `style`.',
reason: 'The board reads `conditionalFormatting` as an ordered list of `{ condition, style }` rules, through '
+ 'the evaluator the grid\'s rows use, and paints a card with the `style` of the first rule whose condition '
+ 'holds; objectui declares exactly the list view\'s rule as the member\'s only dialect. The page-component '
+ 'row declared it `z.unknown()`, so `42`, a bare string or a rule with no `style` passed the '
+ 'component-props gate and the board painted no card for it. The row now takes the list view\'s own '
+ 'member, by reference, as `object-grid` does, so one rule is judged the same way on every door. It is '
+ 'read where every page component\'s props are: the component-props gate reports a refused value as an '
+ 'advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, '
+ '`objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page '
+ 'component\'s `properties` is not parsed on the metadata save or load path. No conversion is '
+ 'registered: nothing on the load path refuses the shape, and the authored census found no working rule '
+ 'to respell. Deployed metadata NOT MEASURED.',
acceptanceCriteria: 'Every `object-kanban` node validates: `objectstack validate` reports no '
+ '`component-props-invalid` / `component-props-unknown-key` finding under '
+ '`properties.conditionalFormatting`. Each board that sets rules paints the card each rule names with '
+ 'its `style`.',
},
// #21464 — `navigation` on the `object-map`, `object-gantt` and `object-tree`
// page blocks was `z.unknown()` although each renderer hands it to the shared
// navigation hook, which reads `.mode` and types its mode union as the list
Expand Down
Loading
Loading