diff --git a/.changeset/10946-expression-wire-slots-by-reference.md b/.changeset/10946-expression-wire-slots-by-reference.md index fd348fac34..def972ddb5 100644 --- a/.changeset/10946-expression-wire-slots-by-reference.md +++ b/.changeset/10946-expression-wire-slots-by-reference.md @@ -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. diff --git a/.changeset/11522-kanban-rule-dialect-retired.md b/.changeset/11522-kanban-rule-dialect-retired.md new file mode 100644 index 0000000000..03b0f3090f --- /dev/null +++ b/.changeset/11522-kanban-rule-dialect-retired.md @@ -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. diff --git a/.changeset/7664-kanban-arm-plugin-dialect.md b/.changeset/7664-kanban-arm-plugin-dialect.md index ade73ca36e..68e1a06c4a 100644 --- a/.changeset/7664-kanban-arm-plugin-dialect.md +++ b/.changeset/7664-kanban-arm-plugin-dialect.md @@ -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. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 66e10a6e3f..e907927a1e 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2878,7 +2878,7 @@ const MEMBER_PINS: Record = { }, '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', diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 4982c72cd0..9d59b01d63 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -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`. diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 894f27d8e7..dec3b40112 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -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 { @@ -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( diff --git a/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx b/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx index eb99f656c8..5542c1c73a 100644 --- a/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx +++ b/packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx @@ -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 * @@ -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)' } }, ], }); @@ -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)' } }, ], }); @@ -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 diff --git a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx index 5f215fffb9..9e6e0447c8 100644 --- a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx +++ b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx @@ -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' } }; diff --git a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts index 06c64600b0..bcf38c41b3 100644 --- a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts +++ b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts @@ -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; diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 66e0f1bd5a..7a241642ec 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -672,7 +672,7 @@ const OBJECT_KANBAN_INPUTS: ComponentInput[] = [ { name: 'data', type: 'array', description: 'Inline records to render instead of fetching. Authoring it SUPPRESSES the board’s own query entirely. Members are records: the board reads `id` (or `_id`) as the card identity, the `groupBy` field’s value as the lane, the card-title field, `coverImageField`, and every `cardFields` entry. Records handed down by a parent view and a `bind` expression both take priority over it.' }, { name: 'cardFields', type: 'array', description: 'Record field NAMES rendered as cells on each card, in the order written. Members are bare names, not entry objects. An explicit list wins over the object’s `highlightFields` role; unlike that fallback it is NOT filtered against the object definition, so a name the object no longer declares simply renders no cell. An empty array reads as omitted.' }, { name: 'grouping', type: 'object', description: 'Only `grouping.fields[0].field` is read, and only as the FALLBACK for `swimlaneField`: it names the record field that splits the board into horizontal swimlanes when no `swimlaneField` is authored. An explicit `swimlaneField` wins. Every other position inside `grouping`, later `fields` entries included, is inert on this board.' }, - { name: 'conditionalFormatting', type: 'array', description: 'Per-card style rules, each evaluated against that card’s own record. Two member dialects are accepted: the native `{ field, operator, value, backgroundColor?, borderColor? }` and the spec CEL `{ condition, backgroundColor?, borderColor? }`. A matching rule colours that card alone. A rule comparing a relation field sees the stored foreign key rather than the expanded record.' }, { name: 'navigation', type: 'object', description: 'What a card click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the record in a drawer. `mode` is an overlay (`drawer`, `modal`, `split`, `popover`), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `page` default. `page` opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, there is no record page to open and the click opens nothing. `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, `preventNavigation: true` opens nothing, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.' }, + { name: 'conditionalFormatting', type: 'array', description: 'Per-card style rules, each `{ condition, style }` — a CEL `condition` over the card’s own `record.*` and a CSS `style` map, the rule a list view declares. The first matching rule styles that card alone. A rule comparing a relation field sees the stored foreign key rather than the expanded record. The native `{ field, operator, value }` rule and a colour written beside `condition` instead of inside `style` are retired and refused by name (objectui#11522).' }, { name: 'navigation', type: 'object', description: 'What a card click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the record in a drawer. `mode` is an overlay (`drawer`, `modal`, `split`, `popover`), `new_window`, `page` or `none`, and a block written without `mode` takes the spec’s `page` default. `page` opens the record page through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, there is no record page to open and the click opens nothing. `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, `preventNavigation: true` opens nothing, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.' }, ]; ComponentRegistry.register( diff --git a/packages/types/src/__tests__/kanban-conditional-formatting.test.ts b/packages/types/src/__tests__/kanban-conditional-formatting.test.ts index 10a2229684..7ed0b1d508 100644 --- a/packages/types/src/__tests__/kanban-conditional-formatting.test.ts +++ b/packages/types/src/__tests__/kanban-conditional-formatting.test.ts @@ -7,67 +7,192 @@ */ /** - * Kanban conditional formatting accepts CEL (#1584). + * `object-kanban`'s `conditionalFormatting` speaks ONE rule dialect — the spec + * list view's `{ condition, style }` — and refuses the two it used to take BY + * NAME (objectui#11522; triage ruling 5963861071: retire, not widen, no alias + * window). * - * Since kanban card styling moved onto the shared CEL evaluator, the kanban - * schema's type + zod contract must match the runtime: a rule may be the native - * `{ field, operator, value }` shape OR the spec `{ condition, style }` CEL - * shape. This locks both so the two can't drift back apart. + * TURNED AROUND, not deleted. This file used to pin that a kanban rule accepted + * BOTH the native `{ field, operator, value }` comparison and the spec shape + * (#1584). It now pins the opposite, for the two retired dialects: + * + * - the native rule `{ field, operator, value, backgroundColor, borderColor }`, + * - the flat CEL rule — a `condition` with a colour at the TOP LEVEL + * (`{ condition, backgroundColor }`) instead of inside `style`. + * + * Each refusal is read on all three zod faces an author meets — the + * `ObjectKanbanSchema` mirror, the tolerant document face + * (`safeValidateSchema`) and the strict authoring face + * (`StrictAnyComponentSchema`) — at the retired key's OWN path, and the message + * there names the retirement and the respelling. A bare `success: false` would + * not be a refusal pin: a document with an unrelated mistake fails too. The + * accepted `{ condition, style }` rule in the same document is the live control + * on every face. + * + * The TS face is pinned by `tsc -p tsconfig.test.json` (this package's + * `type-check`): the `@ts-expect-error` lines below fail the build the moment a + * retired key type-checks again. */ -import { describe, it, expect } from 'vitest'; -import { ObjectKanbanSchema } from '../zod/index.zod'; -import type { KanbanConditionalFormattingRule } from '../objectql'; - -describe('kanban conditionalFormatting — zod contract', () => { - // `groupBy`, not `groupField`: the lane key the renderer reads, declared on - // both faces by objectui#7322 (which retired `groupField` on this node). - const base = { type: 'object-kanban', objectName: 'task', groupBy: 'status' }; - - it('accepts the native { field, operator, value } rule (back-compat)', () => { - const parsed = ObjectKanbanSchema.safeParse({ - ...base, - conditionalFormatting: [ - { field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }, - ], - }); - expect(parsed.success).toBe(true); +import { describe, it, expect, expectTypeOf } from 'vitest'; +import type { z } from 'zod'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ObjectKanbanSchema, safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod'; +import { KanbanConditionalFormattingRuleSchema } from '../zod/objectql.zod'; +import type { KanbanConditionalFormattingRule, SpecConditionalFormattingRule } from '../objectql'; + +// `groupBy`, not `groupField`: the lane key the renderer reads, declared on +// both faces by objectui#7322 (which retired `groupField` on this node). +const base = { type: 'object-kanban', objectName: 'task', groupBy: 'status' }; + +/** The accepted spelling — and the live control in every mixed document below. */ +const SPEC_RULE = { condition: "record.status == 'done'", style: { backgroundColor: '#e0ffe0' } }; + +/** The retired native rule, exactly as this file used to accept it. */ +const NATIVE_RULE = { field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }; + +/** The retired flat CEL rule: the colour beside `condition`, not inside `style`. */ +const FLAT_CEL_RULE = { condition: "record.owner == 'bob'", backgroundColor: 'rgb(4, 5, 6)' }; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Face = readonly [label: string, parse: (doc: unknown) => { success: boolean; error?: { issues: Issue[] } }]; + +const FACES: readonly Face[] = [ + ['ObjectKanbanSchema', (doc) => ObjectKanbanSchema.safeParse(doc) as never], + ['safeValidateSchema (tolerant face)', (doc) => safeValidateSchema(doc) as never], + ['StrictAnyComponentSchema (strict face)', (doc) => StrictAnyComponentSchema.safeParse(doc) as never], +]; + +/** The issue each face reports at `conditionalFormatting[index].key`, if any. */ +function issueAt(face: Face, rules: unknown[], index: number, key: string): Issue | undefined { + const parsed = face[1]({ ...base, conditionalFormatting: rules }); + return parsed.error?.issues.find( + (i) => i.path.join('.') === `conditionalFormatting.${index}.${key}`, + ); +} + +/** What every retirement message must carry: the card and the one spelling that replaces the rule. */ +function expectRetirement(issue: Issue | undefined, key: string): void { + expect(issue, `no issue at the retired key \`${key}\``).toBeDefined(); + expect(issue!.message.startsWith(`\`${key}\``), issue!.message).toBe(true); + expect(issue!.message).toContain('RETIRED (objectui#11522)'); + expect(issue!.message).toContain('`{ condition, style }`'); +} + +describe.each(FACES)('objectui#11522 — `object-kanban`.`conditionalFormatting` on %s', (...face) => { + it('CONTROL — the spec `{ condition, style }` rule is accepted', () => { + expect(face[1]({ ...base, conditionalFormatting: [SPEC_RULE] }).success).toBe(true); + }); + + it('refuses the native `{ field, operator, value, backgroundColor }` rule BY NAME, at each retired key', () => { + // Index 1: the accepted rule at index 0 draws no issue in the same parse. + const rules = [SPEC_RULE, NATIVE_RULE]; + expect(face[1]({ ...base, conditionalFormatting: rules }).success).toBe(false); + for (const key of ['field', 'operator', 'value']) { + const issue = issueAt(face, rules, 1, key); + expectRetirement(issue, key); + expect(issue!.message).toContain('native kanban rule dialect'); + } + expectRetirement(issueAt(face, rules, 1, 'backgroundColor'), 'backgroundColor'); + const atControl = face[1]({ ...base, conditionalFormatting: rules }).error!.issues.filter( + (i) => i.path.join('.').startsWith('conditionalFormatting.0'), + ); + expect(atControl).toEqual([]); + }); + + it('refuses the flat CEL rule — a top-level colour beside `condition` — BY NAME, with the `style` respelling', () => { + const rules = [SPEC_RULE, FLAT_CEL_RULE]; + const issue = issueAt(face, rules, 1, 'backgroundColor'); + expectRetirement(issue, 'backgroundColor'); + expect(issue!.message).toContain('`style: { backgroundColor }`'); + // ⛔ Not "the generic union failure": with one dialect left there is no + // union to fail, and no issue sits at the bare rule path. + const parsed = face[1]({ ...base, conditionalFormatting: rules }); + expect(parsed.error!.issues.some((i) => i.code === 'invalid_union' && i.path.join('.') === 'conditionalFormatting.1')).toBe(false); + }); + + it('a top-level colour is refused even beside a `style` map, and so are the other two colour keys', () => { + // The tolerant face used to ACCEPT `{ condition, style, backgroundColor }` + // (the spec arm was a stripping `z.object`) while the shared resolver + // painted the stripped key over `style` anyway. + for (const key of ['backgroundColor', 'borderColor', 'textColor']) { + const rules = [{ ...SPEC_RULE, [key]: 'red' }]; + expectRetirement(issueAt(face, rules, 0, key), key); + } + const textColor = issueAt(face, [{ ...SPEC_RULE, textColor: 'red' }], 0, 'textColor'); + expect(textColor!.message).toContain('`style: { color }`'); + }); +}); + +describe('objectui#11522 — the kanban rule IS the spec list view\'s, by reference', () => { + const specRule = SpecListViewSchema.shape.conditionalFormatting.unwrap().element; + type Shaped = { shape: Record }; + + it('`style` is the spec rule\'s own member (identity, not a copy)', () => { + expect((KanbanConditionalFormattingRuleSchema as unknown as Shaped).shape.style).toBe(specRule.shape.style); }); - it('accepts the spec { condition, style } CEL rule (new)', () => { - const parsed = ObjectKanbanSchema.safeParse({ - ...base, - conditionalFormatting: [ - { condition: "record.status == 'done'", style: { backgroundColor: '#e0ffe0' } }, - ], - }); - expect(parsed.success).toBe(true); + it('CONTROL — the identity check can fail: `condition` is the list view\'s own arm, not the bare spec slot', () => { + expect((KanbanConditionalFormattingRuleSchema as unknown as Shaped).shape.condition).not.toBe(specRule.shape.condition); }); - it('accepts a mix of both shapes in one rule list', () => { - const parsed = ObjectKanbanSchema.safeParse({ - ...base, - conditionalFormatting: [ - { condition: "record.blocked == true", style: { borderColor: 'red' } }, - { field: 'priority', operator: 'in', value: ['high', 'urgent'], backgroundColor: '#fef9c3' }, - ], - }); - expect(parsed.success).toBe(true); + it('inherits the spec rule\'s strictness: an undeclared key is refused with the spec\'s own message', () => { + const parsed = ObjectKanbanSchema.safeParse({ ...base, conditionalFormatting: [{ ...SPEC_RULE, label: 'Done' }] }); + expect(parsed.success).toBe(false); + const issue = parsed.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual(['conditionalFormatting', 0]); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['label']); + // The spec rule alone draws the same refusal — the strictness is the spec's, not a local copy. + expect(specRule.safeParse({ ...SPEC_RULE, label: 'Done' }).success).toBe(false); }); }); -describe('kanban conditionalFormatting — type contract', () => { - it('KanbanConditionalFormattingRule admits both shapes at compile time', () => { - const native: KanbanConditionalFormattingRule = { - field: 'priority', - operator: 'equals', - value: 'high', - backgroundColor: '#fee2e2', - }; - const cel: KanbanConditionalFormattingRule = { - condition: "record.status == 'done'", - style: { backgroundColor: '#e0ffe0' }, - }; - expect(native).toBeTruthy(); - expect(cel).toBeTruthy(); +/* ── the TS face (tsc is the pin) ────────────────────────────────────────── */ + +const accepted: KanbanConditionalFormattingRule = SPEC_RULE; + +const envelope: KanbanConditionalFormattingRule = { + condition: { dialect: 'cel', source: "record.status == 'done'" }, + style: { borderColor: 'red' }, +}; + +const nativeRefused: KanbanConditionalFormattingRule = { + // @ts-expect-error — `field` is `?: never` (objectui#11522): the native rule dialect is retired. + field: 'priority', + // @ts-expect-error — `operator` is `?: never`. + operator: 'equals', + // @ts-expect-error — `value` is `?: never`. + value: 'high', + condition: "record.priority == 'high'", + style: {}, +}; + +const flatColourRefused: KanbanConditionalFormattingRule = { + condition: "record.owner == 'bob'", + style: {}, + // @ts-expect-error — a top-level colour is `?: never` (objectui#11522): it belongs in `style`. + backgroundColor: 'rgb(4, 5, 6)', +}; + +// @ts-expect-error — the flat CEL rule has no `style`, which the rule requires. +const flatCelMissingStyle: KanbanConditionalFormattingRule = { condition: "record.owner == 'bob'" }; + +describe('objectui#11522 — the TS face agrees with the zod face', () => { + it('a `{ condition, style }` rule (string or envelope condition) is the declared rule', () => { + expect(accepted.condition).toBe("record.status == 'done'"); + expect(envelope.style).toEqual({ borderColor: 'red' }); + // The refused literals above are referenced so they are not dead code; tsc is their pin. + expect([nativeRefused, flatColourRefused, flatCelMissingStyle].length).toBe(3); + }); + + it('the two faces declare the same keys, and the rule is the spec-format rule plus the tombstones', () => { + type ZodIn = z.input; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // Every retired key is unwritable on both faces (`?: never` / `z.never().optional()`). + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + // And the accepted rule is exactly the shared spec-format rule. + expectTypeOf().toMatchTypeOf(); }); }); diff --git a/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts b/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts index a9ddf0fc27..2a298a0871 100644 --- a/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts +++ b/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts @@ -193,7 +193,9 @@ describe('the positive direction — the declared members still parse', () => { ['cardFields', ['owner']], // `quickAdd` left this list when objectui#8285 retired it on this arm. ['coverImageField', 'cover'], - ['conditionalFormatting', [{ field: 'status', operator: 'equals', value: 'open' }]], + // `{ condition, style }`: respelled (objectui#11522) from the retired native + // `{ field: 'status', operator: 'equals', value: 'open' }`, now refused by name. + ['conditionalFormatting', [{ condition: "record.status == 'open'", style: { backgroundColor: '#fee2e2' } }]], ])('still accepts the live member `%s`', (key, value) => { expect(refusals({ ...NODE, [key]: value })).toEqual([]); }); diff --git a/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts b/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts index 62fd066950..ff9eaef0d0 100644 --- a/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts +++ b/packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts @@ -102,7 +102,8 @@ describe('objectui#10946 — the TS faces admit the named view\'s expression slo it('the zod mirror\'s `condition` input is the TS member, by construction', () => { type MirrorRule = Extract['conditionalFormatting']>[number], { style: unknown }>; - type KanbanMirrorRule = Extract, { style: unknown }>; + // One dialect since objectui#11522, so there is no union arm to extract. + type KanbanMirrorRule = z.input; expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); @@ -175,13 +176,16 @@ describe('objectui#10946 — the envelope arm IS the spec\'s slot schema (refere expect(condition.options[1]).toBe(specConditionSlot); }); - it('on the kanban rule union', () => { - const condition = ((KanbanConditionalFormattingRuleSchema as unknown as UnionOf).options[1] as RuleArm).shape.condition; + // objectui#11522 retired the kanban rule's native arm, so the rule is ONE + // object now (the spec list view's own, extended) and there is no union to + // index: `condition` is read straight off its shape. + it('on the kanban rule', () => { + const condition = (KanbanConditionalFormattingRuleSchema as unknown as RuleArm).shape.condition; expect(condition.options[1]).toBe(specConditionSlot); }); it('CONTROL — the reference check can fail: the string arm is NOT the spec slot', () => { - const condition = ((KanbanConditionalFormattingRuleSchema as unknown as UnionOf).options[1] as RuleArm).shape.condition; + const condition = (KanbanConditionalFormattingRuleSchema as unknown as RuleArm).shape.condition; expect(condition.options[0]).not.toBe(specConditionSlot); }); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 6adc9db636..e6f55301c1 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4977,8 +4977,11 @@ const EXCLUSIONS: Readonly> = { "a key-name tuple, not a schema — the exclusion list `SpecDashboardFields` and the `DashboardComponentSchema` twin both read (objectui#9736)", 'layout.zod.ts#PAGE_SPEC_EXCLUDED': "a key-name tuple, not a schema — the exclusion list `SpecPageFields` and the `PageNodeSchema` twin both read (objectui#9736)", + // objectui#11522 — no longer a union of two dialects: the spec list view's own + // rule, by reference, plus the retired keys as tombstones. The TS twin is + // compared where it is pinned, the way `ExpressionWireSchema` above is. 'objectql.zod.ts#KanbanConditionalFormattingRuleSchema': - "a union of two rule dialects (native `{ field, operator, value }` | spec `{ condition, style }`) with no `.shape` of its own — exported by objectui#7664 so the `'kanban'` arm (`complex.zod.ts#KanbanSchema`) and the `'object-kanban'` arm mirror `conditionalFormatting` from ONE rule declaration; its TS twin `KanbanConditionalFormattingRule` (`../objectql.ts`) is a type union, not a key set, and both arms' `conditionalFormatting` keys are compared where they are declared", + "the spec list view's `{ condition, style }` rule BY REFERENCE (`ListViewSchema.conditionalFormatting`'s element, `.extend()`-ed), with the condition the list view's own arm reads and the six retired keys of the native and flat-colour dialects as tombstones (objectui#11522) — not a hand-written key set; its TS twin `KanbanConditionalFormattingRule` (`../objectql.ts`) extends `SpecConditionalFormattingRule`, the same reference, and the two faces' key sets and values are pinned equal in `kanban-conditional-formatting.test.ts`; `ObjectKanbanSchema`'s `conditionalFormatting` key is compared where it is declared", }; /* ── Which pairs depend on @objectstack/spec ────────────────────────────────── */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8df8045b41..e3d3c6fe77 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -494,7 +494,6 @@ export type { ObjectCalendarSchema, ObjectKanbanSchema, KanbanConditionalFormattingRule, - KanbanNativeConditionalFormattingRule, ObjectChartSchema, ObjectGallerySchema, ObjectDataTableSchema, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index d7fc34a206..616d312d59 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -4882,8 +4882,11 @@ export interface ObjectKanbanSchema extends BaseSchema { allowCollapse?: never; /** - * Conditional formatting rules for card coloring. - * Cards are colored based on field values matching conditions. + * Conditional formatting rules for card colouring, each `{ condition, style }` + * evaluated against the card's own record; the first matching rule styles + * the card. The native `{ field, operator, value }` rule and a top-level + * colour key are refused by name (objectui#11522) — see + * {@link KanbanConditionalFormattingRule}. */ conditionalFormatting?: KanbanConditionalFormattingRule[]; @@ -5070,32 +5073,55 @@ export interface ObjectKanbanSchema extends BaseSchema { } /** - * Native (field/operator/value) conditional formatting rule for Kanban cards. - */ -export interface KanbanNativeConditionalFormattingRule { - /** Field name to check */ - field: string; - /** Operator for comparison */ - operator: 'equals' | 'not_equals' | 'contains' | 'in'; - /** Value to compare against */ - value: string | string[]; - /** Background color to apply (Tailwind class or CSS color) */ - backgroundColor?: string; - /** Border color to apply (Tailwind class or CSS color) */ - borderColor?: string; -} - -/** - * Conditional formatting rule for Kanban cards. + * Conditional formatting rule for Kanban cards — ONE dialect, the spec list + * view's `{ condition, style }`: a CEL `condition` over the card's `record.*` + * and a CSS `style` map, first matching rule wins (objectui#11522). + * + * It IS {@link SpecConditionalFormattingRule}, the protocol's rule by reference + * (its `condition` indexes `ObjectListViewSchema`'s own slot, objectui#10946), + * plus the retired keys declared `?: never`, so each is refused BY NAME: + * + * - the native comparison `{ field, operator, value, backgroundColor, + * borderColor }` (#1584 accepted it beside the spec shape; the + * `KanbanNativeConditionalFormattingRule` interface that declared it is + * gone), and + * - a colour written at the TOP LEVEL of a rule — `backgroundColor`, + * `borderColor`, `textColor` — including the "flat CEL" rule + * `{ condition, backgroundColor }`. A colour belongs inside `style` + * (`textColor` is `style.color`). + * + * Retired, not widened, with no alias window (triage ruling 5963861071 on + * objectui#11522; the producer census it rests on is recorded on that card and + * its pull request, not here). `@objectstack/spec` types `object-kanban`'s + * member by reference to the list view's, which refuses both. * - * Since #1584, kanban card styling runs on the shared CEL evaluator, so a rule - * accepts BOTH the native `{ field, operator, value }` shape and the spec - * `{ condition, style }` shape (a CEL predicate + style map) — the same - * `record.*` predicates authors use on list/grid rows. + * Respelling: `{ field: 'priority', operator: 'equals', value: 'high', + * backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", + * style: { backgroundColor: '#fee2e2' } }`. + * + * ⚠️ What did NOT narrow: the shared evaluator. `@object-ui/core`'s + * `resolveConditionalFormatting` still reads every arm, because the grid's and + * the list view's {@link ConditionalFormattingRule} still declares them; the + * board paints whatever a relay hands it. A `{ condition, style }` rule paints + * a card exactly as it did before. + * + * The zod twin is `KanbanConditionalFormattingRuleSchema` in + * `./zod/objectql.zod.ts`, each retired key a `retirementTombstone()` there. */ -export type KanbanConditionalFormattingRule = - | KanbanNativeConditionalFormattingRule - | SpecConditionalFormattingRule; +export interface KanbanConditionalFormattingRule extends SpecConditionalFormattingRule { + /** @deprecated RETIRED (objectui#11522) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + field?: never; + /** @deprecated RETIRED (objectui#11522) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + operator?: never; + /** @deprecated RETIRED (objectui#11522) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + value?: never; + /** @deprecated RETIRED (objectui#11522) — a top-level colour. Write `style: { backgroundColor }`. */ + backgroundColor?: never; + /** @deprecated RETIRED (objectui#11522) — a top-level colour. Write `style: { borderColor }`. */ + borderColor?: never; + /** @deprecated RETIRED (objectui#11522) — a top-level colour. Write `style: { color }`. */ + textColor?: never; +} /** * Object Chart Component Schema — the node `plugin-charts`' `ObjectChart` diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 04c4bbc227..4813c9f33a 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2916,30 +2916,77 @@ export const ObjectCalendarSchema = BaseSchema.extend({ /** * ObjectKanban Schema */ -// Since #1584, kanban card styling runs on the shared CEL evaluator, so a -// kanban rule accepts BOTH the native `{ field, operator, value }` shape and the -// spec `{ condition, style }` shape (a CEL predicate + style map) — matching -// list/grid `conditionalFormatting`. The type/schema now match the runtime. +/** + * The guidance a retired kanban rule key is refused with (objectui#11522). + * + * `native` keys belong to the native `{ field, operator, value }` comparison; + * `colour` keys are a colour written at the TOP LEVEL of a rule — beside a CEL + * `condition` (the "flat CEL" rule) or on the native comparison. Each message + * names the key, the retirement and the one spelling that replaces it. + */ +function kanbanRuleKeyRetired(key: string, kind: 'native' | 'colour'): string { + const lead = + kind === 'native' + ? `\`${key}\` belongs to the native kanban rule dialect \`{ field, operator, value, backgroundColor, borderColor }\`, ` + : `\`${key}\` is a colour written at the top level of the rule, `; + const into = key === 'textColor' ? '`style: { color }`' : `\`style: { ${key} }\``; + return ( + lead + + 'which `object-kanban`\'s `conditionalFormatting` no longer accepts: RETIRED (objectui#11522), with no alias window. ' + + 'A rule is `{ condition, style }` — a CEL `condition` over `record.*` and a CSS `style` map, the rule ' + + '`@objectstack/spec`\'s `ListViewSchema.conditionalFormatting` declares. ' + + (kind === 'native' + ? 'Respell `{ field: \'priority\', operator: \'equals\', value: \'high\', backgroundColor: \'#fee2e2\' }` as ' + + '`{ condition: "record.priority == \'high\'", style: { backgroundColor: \'#fee2e2\' } }` ' + + '(`not_equals` is `!=`, `contains` is `.contains(…)`, `in` is `record.f in [ … ]`).' + : `Move the colour into the rule's CSS map: ${into}.`) + ); +} + +// objectui#11522 — `object-kanban`'s `conditionalFormatting` speaks ONE rule +// dialect, the spec list view's `{ condition, style }`, and refuses the two it +// used to take BY NAME (triage ruling 5963861071: retire, not widen). // -// Exported since objectui#7664 so `complex.zod.ts`'s `KanbanSchema` (the -// `'kanban'` arm) mirrors `conditionalFormatting` with the SAME rule union as -// this `'object-kanban'` arm — one declaration of the rule, two arms. It is a -// union of two rule dialects with no `.shape` of its own, so the parity census -// EXCLUDES it rather than pairing it; its TS twin is the type union -// `KanbanConditionalFormattingRule` (`../objectql.ts`). -export const KanbanConditionalFormattingRuleSchema = z.union([ - z.object({ - field: z.string().describe('Field name to check'), - operator: z.enum(['equals', 'not_equals', 'contains', 'in']).describe('Comparison operator'), - value: z.union([z.string(), z.array(z.string())]).describe('Value to compare against'), - backgroundColor: z.string().optional().describe('Background color'), - borderColor: z.string().optional().describe('Border color'), - }), - z.object({ - condition: SpecRuleConditionSchema.describe('CEL predicate evaluated against the card record'), - style: z.record(z.string(), z.string()).describe('CSS styles applied when the condition is true'), - }), -]); +// The rule is the protocol's own: `ListViewSchema.conditionalFormatting[]`'s +// element, read BY REFERENCE through the import boundary and `.extend()`-ed — +// so it inherits that element's strictness (an undeclared key is refused with +// the spec's own "Unrecognized key(s) on this conditional formatting rule" +// message) and its `style` map, and moves with the installed spec. Two things +// are layered on top, and only two: +// +// - `condition` is `SpecRuleConditionSchema`, the list view's and the grid's +// own condition (objectui#10946): the spec slot by reference behind a +// `z.string()` arm, so a string condition is not canonicalized into an +// envelope and `''` is still accepted — the kanban rule judges a condition +// exactly as the list view's `{ condition, style }` arm does. +// - the retired keys are DECLARED and unwritable (`retirementTombstone()`), +// so each is refused at its own path with the remedy instead of being one +// more unrecognized key: the native comparison's `field` / `operator` / +// `value`, and the three top-level colour keys the shared resolver +// (`resolveConditionalFormatting`) would otherwise still paint with — +// `backgroundColor` and `borderColor` (the native rule's, and the flat CEL +// rule's), and `textColor`. `z.input` of each is `undefined`, which is the +// TS twin's `?: never`. +// +// Before this, the member was a union of the native comparison and the spec +// shape (#1584), exported by objectui#7664 for the since-retired `'kanban'` +// arm. ⚠️ The shared resolver is NOT narrowed: the grid's and the list view's +// rule union (`ConditionalFormattingRuleSchema` above) still declares every +// arm it reads, so the board itself still paints whatever a relay hands it. +// What retired is the AUTHORED kanban member. Its TS twin is +// `KanbanConditionalFormattingRule` (`../objectql.ts`); the two faces are +// pinned equal in `../__tests__/kanban-conditional-formatting.test.ts`. +const SpecListViewRuleSchema = stripImportedDefaults(SpecListViewSchema).shape.conditionalFormatting.unwrap().element; + +export const KanbanConditionalFormattingRuleSchema = SpecListViewRuleSchema.extend({ + condition: SpecRuleConditionSchema.describe('CEL predicate evaluated against the card record'), + field: retirementTombstone(kanbanRuleKeyRetired('field', 'native')), + operator: retirementTombstone(kanbanRuleKeyRetired('operator', 'native')), + value: retirementTombstone(kanbanRuleKeyRetired('value', 'native')), + backgroundColor: retirementTombstone(kanbanRuleKeyRetired('backgroundColor', 'colour')), + borderColor: retirementTombstone(kanbanRuleKeyRetired('borderColor', 'colour')), + textColor: retirementTombstone(kanbanRuleKeyRetired('textColor', 'colour')), +}); /** * The `object-kanban` board has a record source — at least one of `bind`, diff --git a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts index 600bed9be0..0cd8a187d3 100644 --- a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts +++ b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts @@ -55,9 +55,10 @@ import { blank, scanSource } from '../js-comment-mask.mjs'; * * ⛔ **Near-spellings and derived names are NOT collisions.** The matcher * anchors the name on both ends, so `ConditionalFormattingRule` does not match - * `KanbanConditionalFormattingRule` (`packages/types/src/objectql.ts:2202`) or - * `KanbanNativeConditionalFormattingRule` (same file, :2181) — both of which - * this repository really writes, next to the contested name. + * `KanbanConditionalFormattingRule` (declared in `packages/types/src/objectql.ts`, + * next to the contested name) or `KanbanNativeConditionalFormattingRule` — a + * derived name this repository declared there until objectui#11522 retired it, + * kept below as a synthetic near-spelling. * * ## Two stated bounds, because a claim must be bounded or derivable * @@ -647,8 +648,9 @@ describe('objectui#6273 — the matcher discriminates', () => { { rel: 'packages/types/src/app.ts', source: 'export interface MenuItem { id: string }' }, { rel: 'packages/types/src/index.ts', source: "export type { MenuItem as AppMenuItem } from './app';" }, ], - // Derived names next to the contested one — both real, both in - // packages/types/src/objectql.ts. + // Derived names next to the contested one. `KanbanConditionalFormattingRule` + // is real (packages/types/src/objectql.ts); the `KanbanNative…` spelling + // was, until objectui#11522 retired it, and stays as a synthetic one. [ { rel: 'packages/types/src/objectql.ts', source: 'export interface KanbanNativeConditionalFormattingRule { a: 1 }' }, { rel: 'packages/plugin-kanban/src/x.tsx', source: 'export type ConditionalFormattingRule = { a: 1 };' },