diff --git a/.changeset/21464-component-props-kanban-conditional-formatting-typed.md b/.changeset/21464-component-props-kanban-conditional-formatting-typed.md new file mode 100644 index 00000000000..6a8dc2df7b5 --- /dev/null +++ b/.changeset/21464-component-props-kanban-conditional-formatting-typed.md @@ -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) + + + +**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. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d06f6e5e985..de50e449ad5 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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 }[]` | 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]` @@ -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` | ✅ | CSS styles to apply when condition is true | + ### Nested Shape: `ObjectKanbanProps.navigation` | Property | Type | Required | Description | diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-kanban-conditional-formatting-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-kanban-conditional-formatting-typed.ts new file mode 100644 index 00000000000..fe4884cdbc8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-kanban-conditional-formatting-typed.ts @@ -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`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 020ac4a9d6d..2a7f5f9d033 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -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, @@ -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 diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index d448ecb5c74..1a874189f1d 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -29,6 +29,9 @@ * `object-gantt` and `object-tree` is the list view's * `NavigationConfigSchema`, by identity, and refuses an off-shape value with * the code AND the path; its ADR-0087 D3 entry is registered. + * - §5 THE HOLD THIS FILE RECORDED, EXITED: `object-kanban` + * `conditionalFormatting` is the list view's own member, by identity, the + * same way (the S-kanban-cf stage). * * Later stages pin the members they type in their own file, beside this one: * the list family (`object-grid` `columns` / `fields` / `selection` / @@ -47,7 +50,10 @@ * `component-objectui-held-typed-members.pin.test.ts`; the rest of them are * held below as forks — the drill-down's `report`, the form's `customFields` * and both forms' `sections`, the timeline's `items` and the action - * containers' members. + * containers' members. The one member this ledger held for a ruling, + * `object-kanban` `conditionalFormatting`, exited its hold once objectui's + * kanban declared the list view's rule as its only dialect, and is pinned + * in §5 below, where its hold was recorded. * * ## The STAGED reason is debt, not a verdict * @@ -71,11 +77,12 @@ import { z } from 'zod'; import { ComponentPropsMap, ObjectGanttPropsSchema, + ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectTreePropsSchema, pageComponentSlotPositions, } from './component.zod'; -import { NavigationConfigSchema } from './view.zod'; +import { ListViewSchema, NavigationConfigSchema } from './view.zod'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; // ─────────────────────────────────────────────────────────────────────────── @@ -141,7 +148,6 @@ function unknownMembers(schema: unknown): UnknownMember[] { */ const STAGES = { 'fork': 'element contracts whose declaration is still objectui\'s (the timeline items, `UIActionSchema` — an objectui interface that borrows some members from the spec `Action` — and the runtime form field `FormField`, identity key `name`, which a form section\'s inline entry is too) and the metric drill-down\'s `report`: the S-objectui-held stage, the last of #21464, measured each and found two or more viable spec shapes that no ruling decides, so under the stop valve each is held and its fork reported on the card with its census; the member is typed once a ruling picks a shape', - 'held-for-decision': 'a typed shape exists (by reference, or the renderer\'s own declared type), but measured writers author values it refuses that the renderer draws — the narrowing waits for a ruling', } as const; type Stage = keyof typeof STAGES; @@ -188,7 +194,6 @@ const BULK_OPTION_ENTRY: Reason = { const RECORDS: Reason = { kind: 'records' }; const SLOT: Reason = { kind: 'slot' }; const RUNNER: Reason = { kind: 'runner' }; -const staged = (stage: Stage, reader: string): Reason => ({ kind: 'staged', stage, reader }); const fork = (reader: string, shapes: readonly string[]): Reason => ({ kind: 'staged', stage: 'fork', reader, shapes }); /** @@ -216,6 +221,7 @@ on(['action:button', 'action:icon'], ['visible.ast', 'disabled.ast'], EXPRESSION on(['record:line_items'], ['columns[].readonlyWhen.ast', 'columns[].requiredWhen.ast'], EXPRESSION_AST); on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'details[].columns[].requiredWhen.ast'], EXPRESSION_AST); on(['object-grid'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast'], EXPRESSION_AST); +on(['object-kanban'], ['conditionalFormatting[].condition.ast'], EXPRESSION_AST); // The action blocks' runner-forwarded members. on(['action:button', 'action:icon'], ACTION_RUNNER_PATHS, RUNNER); @@ -247,7 +253,7 @@ on(['object-grid'], ['pagination.*'], { }); // Read with a fixed shape at the pin — the forks the S-objectui-held stage -// reported, and the one member still held for a ruling. +// reported. // // The metric tile's four members are typed (stages 4 and 5); the drill-down's // `report` is not. The tile hands it to the shared drawer, which draws a @@ -313,13 +319,6 @@ on(['action:menu'], ['actions[]{}'], fork( 'the read set with `outcomeMessages`, a member `className` and the member `properties.params` bag declared', ], )); -// The list view's own `conditionalFormatting` is the by-reference shape, as on -// `object-grid` (#21445) — but objectui's own kanban fixtures author both rule -// dialects it refuses (`plugin-kanban/src/__tests__/ObjectKanban. -// structuredMembersReachTheirSinks-8313.test.tsx:463-485`, -// `types/src/__tests__/kanban-conditional-formatting.test.ts:29-52`), so the -// narrowing is reported for a ruling instead of shipped. -on(['object-kanban'], ['conditionalFormatting'], staged('held-for-decision', 'plugin-kanban/src/KanbanBoardCore.tsx:114, evaluated at KanbanImpl.tsx:179 (`resolveConditionalFormatting`)')); /** Every `z.unknown()` member of every row, keyed as the ledger keys it. */ function census(): Map { @@ -505,3 +504,73 @@ describe('§4 `navigation` on object-map / object-gantt / object-tree is the lis expect(MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id)).toContain('ui-object-map-gantt-tree-navigation-typed'); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// §5 the hold this ledger recorded, exited +// ─────────────────────────────────────────────────────────────────────────── + +describe('§5 `conditionalFormatting` on object-kanban is the list view\'s own member', () => { + const row = () => ComponentPropsMap['object-kanban']; + const BASE = { objectName: 'deal' } as const; + const RULE = { condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } } as const; + + /** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ + const issues = (result: z.ZodSafeParseResult): { code: string; path: string }[] => + result.success ? [] : result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); + + it('unwraps to the list view\'s member — the same array def', () => { + expect(ObjectKanbanPropsSchema.shape.conditionalFormatting.unwrap()._zod.def) + .toBe(ListViewSchema.shape.conditionalFormatting.unwrap()._zod.def); + }); + + // The census writers' two spellings of a condition — the bare CEL string and + // the `{ dialect, source }` envelope — and an empty list. + const ACCEPTED: ReadonlyArray = [ + ['a string condition', [RULE]], + ['an envelope condition', [{ condition: { dialect: 'cel', source: "record.owner == 'ann'" }, style: { color: 'red' } }]], + ['an empty list', []], + ]; + for (const [label, rules] of ACCEPTED) { + it(`parses ${label} to exactly what the list view's member answers`, () => { + const r = row().safeParse({ ...BASE, conditionalFormatting: rules }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { conditionalFormatting?: unknown }).conditionalFormatting) + .toStrictEqual(ListViewSchema.shape.conditionalFormatting.parse(rules)); + }); + } + + const REFUSED: ReadonlyArray]> = [ + ['a number', 42, [{ code: 'invalid_type', path: 'conditionalFormatting' }]], + ['one rule not in a list', RULE, [{ code: 'invalid_type', path: 'conditionalFormatting' }]], + ['a rule with no `style`', [{ condition: RULE.condition }], [{ code: 'invalid_type', path: 'conditionalFormatting.0.style' }]], + ['a blank condition', [{ ...RULE, condition: '' }], [{ code: 'invalid_union', path: 'conditionalFormatting.0.condition' }]], + ['a non-string style value', [{ ...RULE, style: { width: 5 } }], [{ code: 'invalid_type', path: 'conditionalFormatting.0.style.width' }]], + ['the native rule', [{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }], [ + { code: 'invalid_union', path: 'conditionalFormatting.0.condition' }, + { code: 'invalid_type', path: 'conditionalFormatting.0.style' }, + { code: 'unrecognized_keys', path: 'conditionalFormatting.0' }, + ]], + ['a colour beside `condition`', [{ condition: RULE.condition, backgroundColor: '#fee2e2' }], [ + { code: 'invalid_type', path: 'conditionalFormatting.0.style' }, + { code: 'unrecognized_keys', path: 'conditionalFormatting.0' }, + ]], + ['a colour beside `style`', [{ ...RULE, textColor: 'red' }], [{ code: 'unrecognized_keys', path: 'conditionalFormatting.0' }]], + ]; + for (const [label, rules, found] of REFUSED) { + it(`refuses ${label}`, () => { + const r = row().safeParse({ ...BASE, conditionalFormatting: rules }); + expect(r.success).toBe(false); + expect(issues(r)).toEqual(found); + }); + } + + it('an absent conditionalFormatting stays absent', () => { + const r = row().safeParse(BASE); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).not.toHaveProperty('conditionalFormatting'); + }); + + it('is registered as the ADR-0087 D3 entry step 18 carries', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id)).toContain('ui-object-kanban-conditional-formatting-typed'); + }); +}); diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 216b90c947b..0d0ce5ac087 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3940,11 +3940,13 @@ describe('ObjectKanbanPropsSchema quickAdd is retired (#17260)', () => { // `conditionalFormatting` travel the same forward and ARE read // (`KanbanRenderer` / `bucketCardsIntoColumns` at the same pin), so a // sweep that took the whole forwarded list would be over-wide — this is - // the pin that would catch it. + // the pin that would catch it. The rule is the `{ condition, style }` the + // member takes since #21464 (respelled from a `{ field, value }` entry with + // no `operator`, which the board's evaluator skipped as no predicate). const parsed = kanban.safeParse({ objectName: 'showcase_task', coverImageField: 'cover', - conditionalFormatting: [{ field: 'priority', value: 'high' }], + conditionalFormatting: [{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }], }); expect(parsed.success).toBe(true); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 1a6b65102a1..830a02f619c 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -30,7 +30,8 @@ import { // height and row colour schemas, and `.conditionalFormatting` is the list // view's own member (read off `ListViewSchema.shape`, the one place that // declares the rule shape): the grid reads each with exactly that shape, so - // one declaration judges both doors. + // one declaration judges both doors. [#21464] `object-kanban`'s + // `.conditionalFormatting` is the same member, for the same reason. RowHeightSchema, RowColorConfigSchema, ListViewSchema, @@ -5452,7 +5453,48 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', ), coverImageField: z.string().optional().describe('Image field rendered as the card cover'), - conditionalFormatting: z.unknown().optional().describe('Card conditional formatting rules'), + /** + * [#21464] The list view's own `conditionalFormatting` member, by reference, + * as on `object-grid` (#21445): an ordered array of `{ condition, style }`, + * the first rule whose CEL predicate holds styling the card. Until this + * stage it was `z.unknown()`, so `42`, a bare string or a rule with no + * `style` passed the component-props gate and the board painted nothing. + * + * Read at the `.objectui-sha` pin `ab1879721595` (`KanbanBoardCore.tsx`, + * `KanbanImpl.tsx`, `listConditional.ts` and `ObjectGrid.tsx` are + * byte-identical at objectui `main` `2e818d0b51`, where the two + * `@object-ui/types` files cited below changed only around `grouping`): + * `KanbanBoardCore.tsx:114` hands + * `schema.conditionalFormatting` to the board, whose `getCardStyles` + * (`KanbanImpl.tsx:182`) evaluates it through the shared + * `resolveConditionalFormatting` (`core/src/evaluator/listConditional.ts:532`) + * — the evaluator the grid's rows use (`ObjectGrid.tsx:2970`), so one + * declaration judges both doors. objectui's own authoring declaration is this + * rule, by reference, since objectui#11522 (landed by objectui#11532, + * `c73cdb5695`, an ancestor of the pin): `KanbanConditionalFormattingRuleSchema` + * extends the list view's rule element (`types/src/zod/objectql.zod.ts:3074`) + * and refuses the native `{ field, operator, value, … }` and top-level colour + * keys by name, and its TS twin extends `SpecConditionalFormattingRule` + * (`types/src/objectql.ts:5166`). + * + * ⚠️ Two differences, neither a working rule. The shared evaluator still reads + * the native, `expression` and top-level colour arms for a rule a relay hands + * the board in a stored dialect (`KanbanImpl.tsx:160-166`), as on the grid; no + * authored `object-kanban` writer uses them (census below). And objectui's + * `condition` takes a blank string, which the evaluator answers with its + * no-match fallback (`listConditional.ts:252-260`, `fallback: false` at + * `:546`); the list view's member refuses it, as it does on the grid. + * + * Census of authored `object-kanban` writers (objectstack `16d241a6af`, + * objectui at the pin and at `main`, hotcrm `4054ec2680`, cloud + * `2205b53010`): every working writer is a `{ condition, style }` rule and + * parses here. The refused values are objectui's own refusal probes and one + * no-predicate fixture in this package (`{ field, value }` with no + * `operator`, which the evaluator skips; respelled by this stage); hotcrm and + * cloud author none. + */ + conditionalFormatting: ListViewSchema.shape.conditionalFormatting + .describe('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'), /** * Card-click navigation (commit e233db9db — the spec half of the objectui#8652 * maintainer ruling, verbatim `B`: declare `navigation` on the platform