diff --git a/.changeset/10946-expression-wire-slots-by-reference.md b/.changeset/10946-expression-wire-slots-by-reference.md index def972ddb5..bc24c0156c 100644 --- a/.changeset/10946-expression-wire-slots-by-reference.md +++ b/.changeset/10946-expression-wire-slots-by-reference.md @@ -46,3 +46,19 @@ 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. + +⚠️ **Dated note, 2026-10-03 — the list view's (and the grid's) rule is no longer a union either — objectui#11533.** +At this change the list view's rule, which `ObjectGridSchema` shares, was also a +union of two dialects (the native `{ field, operator, value, … }` comparison and +`{ condition, style }`), so the sentence above about "the list view's and the +kanban board's rule unions" described two unions. Now neither is one: the grid's +and the list view's rule is ONE object, the spec list view's `{ condition, style }` +rule by reference, with the native rule, its `expression` and a top-level colour +refused by name. It 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 `list-view` and +`object-grid`, and `spec-expression-wire-slots-10946.test.ts` now reads +`condition` straight off the list view's rule as well. `ConditionalFormattingRule` +is still built on `SpecConditionalFormattingRule`, so the widening this entry +describes still reaches it. 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 index 03b0f3090f..119b7a9149 100644 --- a/.changeset/11522-kanban-rule-dialect-retired.md +++ b/.changeset/11522-kanban-rule-dialect-retired.md @@ -28,3 +28,18 @@ Each refusal message names the key, objectui#11522 and the `{ condition, style } **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. + +⚠️ **Dated note, 2026-10-03 — the grid and the list view retired the same dialect — objectui#11533.** +At this change the grid's and the list view's rule was still a union that +declared the native `{ field, operator, value }` comparison and the top-level +colour keys, and the "Not changed" paragraph above gives that as the reason the +shared evaluator keeps every arm. Now that rule retires them too: `object-grid` +and `list-view` take the spec list view's `{ condition, style }` rule only, and +the native rule, its `expression` and a top-level colour are refused by name. +`resolveConditionalFormatting` still keeps every arm, for a different reason: it +is a compatibility read for rules already STORED in the native dialect, so a +board, grid or list view a relay or a stored view hands such a rule still paints +it. The `condition` the kanban rule shares with the grid's and the list view's +rule (called their "`{ condition, style }` arm" above) is still the same schema; +it is now their whole rule rather than one arm of it. The rest of this entry is +kept as the reading of this change. diff --git a/.changeset/11533-grid-listview-rule-dialect-retired.md b/.changeset/11533-grid-listview-rule-dialect-retired.md new file mode 100644 index 0000000000..d582a4a606 --- /dev/null +++ b/.changeset/11533-grid-listview-rule-dialect-retired.md @@ -0,0 +1,33 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-grid': minor +--- + +**BREAKING for authors of `object-grid` and `list-view` row rules, released as `minor`.** `conditionalFormatting` on `ObjectGridSchema` (and so on the `object-view` `table` slot built from it) and on `list-view` takes ONE rule dialect, the spec list view's `{ condition, style }`: a CEL `condition` over the row's `record.*` and a CSS `style` map. The native dialect it used to take beside it is retired with no alias window and refused by name (objectui#11533), as objectui#11522 did for `object-kanban`. + +| Rule on `ObjectGridSchema`, an `object-view` `table` or a `list-view` | Before | Now | +|---|---|---| +| `{ condition, style }` | accepted | accepted, unchanged | +| native `{ field, operator, value, backgroundColor?, textColor?, borderColor?, expression? }` | accepted on every face | refused at `field`, `operator`, `value` and at each colour key and `expression` written | +| `{ expression, backgroundColor }` (no native triple) | refused as a bare union failure | refused at `expression` and the colour key, naming the retirement | +| 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 grid 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#11533 and the `{ condition, style }` respelling, on `ObjectGridSchema`, `ListViewSchema`, `safeValidateSchema` and `StrictAnyComponentSchema` alike. + +**Respelling.** `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. `not_equals` is `!=`, `greater_than` is `>`, `less_than` is `<`, `contains` is `record.f.contains(…)` and `in` is `record.f in [ … ]`. An `expression` predicate becomes the `condition`, written as CEL over `record.*` with no `${…}` wrapper. A top-level colour moves into `style`; `textColor` is `style.color`. + +**Not judged here: an authored `object-grid` node's `properties` bag.** Its members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row by reference (objectui#11276), and the installed spec types that row's `conditionalFormatting` as `unknown`, so the bag judges the member as the installed spec does. objectui does not narrow the spec's row. + +**`@object-ui/types`.** + +- `ConditionalFormattingRule` (exported from `@object-ui/types`) is now an interface that extends `SpecConditionalFormattingRule`, with `field`, `operator`, `value`, `expression`, `backgroundColor`, `borderColor` and `textColor` declared `?: never`. It used to be the union of `ObjectUIConditionalFormattingRule` and `SpecConditionalFormattingRule`. It types the `conditionalFormatting` members of `ObjectGridSchema` and `NamedListView`; the `ListViewSchema` type's member is the zod rule's input, with the same keys. Every published type built on those members follows, for example the rules parameter of `@object-ui/plugin-list`'s `evaluateConditionalFormatting`. +- `ObjectUIConditionalFormattingRule` is removed from `@object-ui/types`. Importing it is a compile error (TS2305). +- The zod rule the `ObjectGridSchema` and `ListViewSchema` mirrors (both on the `@object-ui/types/zod` barrel) share is module-private. 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 as before, so a string condition is still not canonicalized into an envelope and `''` is still accepted. The seven retired keys are retirement tombstones. + +**`@object-ui/plugin-grid`.** The `object-grid` registration's `conditionalFormatting` input description now describes the one rule and names the retired spellings. `ObjectGridProps.schema.conditionalFormatting` follows the narrowed type. + +**Not changed: what a grid or a list view paints.** The shared evaluator, `resolveConditionalFormatting` in `@object-ui/core`, keeps every arm as a compatibility read for rules already STORED in the native dialect, and nothing on the render path parses a stored view, so a grid or list view saved with a native rule still paints exactly as before. A `{ condition, style }` rule paints exactly as before too. Only the authoring faces narrowed. + +Pins: `packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts` pins every refusal on the six zod faces and carriers, the spec rule's identity and strictness, and the TS face. `gridRowDecorationMembers-8071.test.tsx` in `@object-ui/plugin-grid` pins a stored native rule refused at authoring and painted by the real grid, with its respelling as the control, and `ListView.storedRuleDialect-11533.test.tsx` in `@object-ui/plugin-list` pins a stored list view handing its native rules to its grid untouched. 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 e907927a1e..a7941be355 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2810,7 +2810,7 @@ const MEMBER_PINS: Record = { }, 'object-grid.conditionalFormatting': { file: 'packages/plugin-grid/src/__tests__/gridRowDecorationMembers-8071.test.tsx', - pins: 'The members read inside ONE formatting rule, asserted on the `style` attribute of the `` the grid paints. THREE alternative predicate members with a PRECEDENCE between them — one rule carrying `condition`, `expression` and the native `field`/`operator`/`value` triple, each naming a DIFFERENT row, is decided by `condition`; drop it and `expression` decides; drop that and the triple does — so a renderer reading any two of them as equivalent is red in a specific direction rather than merely unpinned. FOUR style members, and the sharp one is a RENAME: `backgroundColor` and `borderColor` keep their authored names on the way to the DOM while `textColor` is read as the CSS `color`, so keeping the authored spelling hands React a key it silently drops — same rules, same background, the text colour gone with nothing thrown. `style` is pinned as the BASE the three colour members override rather than replace (a non-colour member of it survives alongside the override). Two rule-list facts complete it: FIRST-MATCH-WINS with no merge (the second matching rule\'s own `textColor` never lands) and a rule carrying NO predicate member is SKIPPED rather than read as always-true, with the rule after it still deciding. The registration is an `array` arm and the spec row constrains nothing inside a member, so the read site is the whole member contract (objectui#8071 slice 12).', + pins: 'The members read inside ONE formatting rule, asserted on the `style` attribute of the `` the grid paints. THREE alternative predicate members with a PRECEDENCE between them — one rule carrying `condition`, `expression` and the native `field`/`operator`/`value` triple, each naming a DIFFERENT row, is decided by `condition`; drop it and `expression` decides; drop that and the triple does — so a renderer reading any two of them as equivalent is red in a specific direction rather than merely unpinned. FOUR style members, and the sharp one is a RENAME: `backgroundColor` and `borderColor` keep their authored names on the way to the DOM while `textColor` is read as the CSS `color`, so keeping the authored spelling hands React a key it silently drops — same rules, same background, the text colour gone with nothing thrown. `style` is pinned as the BASE the three colour members override rather than replace (a non-colour member of it survives alongside the override). Two rule-list facts complete it: FIRST-MATCH-WINS with no merge (the second matching rule\'s own `textColor` never lands) and a rule carrying NO predicate member is SKIPPED rather than read as always-true, with the rule after it still deciding. ⚠️ Since objectui#11533 the AUTHORED member is ONE dialect, `{ condition, style }`: `expression`, the native triple and the three top-level colour members are refused by name on `ObjectGridSchema` and the list view (`packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts`), so the rows that write them pin STORED-rule reads, which the resolver keeps as compatibility arms; a last block pins the two facts side by side — the stored native rule refused at authoring and painted at render, with its respelling painting the same row. The registration is an `array` arm and the installed spec row constrains nothing inside a member, so the read site is the whole member contract (objectui#8071 slice 12).', }, 'object-grid.operations': { file: 'packages/plugin-grid/src/__tests__/gridOperationsMembers-8071.test.tsx', diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 6afce4e262..67aa8d3342 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -810,6 +810,7 @@ its row below). | `editable` | `boolean` | Enable inline cell editing. | | `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query, a grid that fetches its own rows refuses grouping with an error naming `queryGroupHeaders`. | | `frozenColumns` | `number` | Number of columns frozen on scroll. | +| `conditionalFormatting` | `ConditionalFormattingRule[]` | Row styling rules, each `{ condition, style }` — a CEL `condition` over the row's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` (rather than inside `style`) are retired (objectui#11533): `@object-ui/types` refuses them by name on `ObjectGridSchema`, the `object-view` `table` slot and `list-view`. `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. In an authored node's `properties` bag the member is `@objectstack/spec`'s row member, judged by the installed spec. A grid stored with a retired rule still paints it. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | | `emptyState` | `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). **Not authorable in a document today:** the spec's `object-grid` row does not declare it, so `objectui validate` refuses it in the `properties` bag, and the strict face refuses it on the node; a host mounting `` or composing the node in code can set it. | diff --git a/packages/plugin-grid/src/__tests__/gridRowDecorationMembers-8071.test.tsx b/packages/plugin-grid/src/__tests__/gridRowDecorationMembers-8071.test.tsx index bebda99399..58774f8b1f 100644 --- a/packages/plugin-grid/src/__tests__/gridRowDecorationMembers-8071.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridRowDecorationMembers-8071.test.tsx @@ -30,6 +30,20 @@ * one of which is RENAMED on the way to the DOM. That is the objectui#8068 * criterion: constrain the shape the RENDERER READS, not the registration. * + * ## Most of a rule's members are STORED-rule reads now (objectui#11533) + * + * Since objectui#11533 the grid's authoring faces take ONE rule dialect, the + * spec list view's `{ condition, style }`: `expression`, the native + * `field`/`operator`/`value` triple and the three top-level colour members + * (`backgroundColor`, `borderColor`, `textColor`) are refused by name on + * `ObjectGridSchema` and on the list view. The RENDERER still reads every one + * of them: `resolveConditionalFormatting` keeps each arm as a compatibility + * read for a grid or list view STORED in the retired dialect, and nothing on + * the render path parses a stored node. So the rows below that write those + * members are not authoring examples — they pin what a stored rule still + * paints, and the last `describe` pins the two facts side by side: refused + * at authoring, painted at render. + * * ## Read through the real renderer, asserted on the `` * * Every case below renders the real `ObjectGrid` over inline data and reads the @@ -44,6 +58,8 @@ import { render, screen, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; +import { ObjectGridSchema } from '@object-ui/types/zod'; + import { ObjectGrid } from '../ObjectGrid'; import { registerAllFields } from '@object-ui/fields'; import { ActionProvider } from '@object-ui/react'; @@ -176,7 +192,7 @@ describe('object-grid `rowColor` — the two members the row-className resolver }); }); -describe('object-grid `conditionalFormatting` — the members read inside ONE rule', () => { +describe('object-grid `conditionalFormatting` — the members read inside ONE rule (retired ones as STORED-rule reads, objectui#11533)', () => { it('reads `condition` in preference to `expression` and to the native field/operator/value triple', () => { renderGrid( [ @@ -354,3 +370,35 @@ describe('object-grid — the two decoration keys reach the SAME row and neither expect(rowOf('StyleOnly').style.backgroundColor).toBe('rgb(14, 14, 14)'); }); }); + +describe('object-grid — a rule STORED in a retired dialect is refused at authoring and still paints (objectui#11533)', () => { + /** The native rule, exactly as a grid stored before objectui#11533 carries it. */ + const STORED = { field: 'name', operator: 'equals', value: 'Gamma', backgroundColor: 'rgb(7, 8, 9)' }; + /** Its `{ condition, style }` respelling, the one dialect the authoring faces take. */ + const RESPELLED = { condition: "record.name == 'Gamma'", style: { backgroundColor: 'rgb(7, 8, 9)' } }; + const ROWS = [ + { id: '1', name: 'Beta' }, + { id: '2', name: 'Gamma' }, + ]; + const doc = (rule: unknown) => ({ type: 'object-grid', objectName: 'test_object', conditionalFormatting: [rule] }); + + it('the authoring face refuses the stored native rule BY NAME, and the real grid still paints its row', () => { + const parsed = ObjectGridSchema.safeParse(doc(STORED)); + expect(parsed.success).toBe(false); + const atField = parsed.error!.issues.find((i) => i.path.join('.') === 'conditionalFormatting.0.field'); + expect(atField?.message).toContain('RETIRED (objectui#11533)'); + + // Render is not a validation door: the stored rule reaches the resolver, + // which keeps the native arm as a compatibility read. + renderGrid(ROWS, { conditionalFormatting: [STORED] }); + expect(rowOf('Gamma').style.backgroundColor).toBe('rgb(7, 8, 9)'); + expect(rowOf('Beta').style.backgroundColor).toBe(''); + }); + + it('CONTROL — the respelling is accepted at authoring and paints the SAME row with the same colour', () => { + expect(ObjectGridSchema.safeParse(doc(RESPELLED)).success).toBe(true); + renderGrid(ROWS, { conditionalFormatting: [RESPELLED] }); + expect(rowOf('Gamma').style.backgroundColor).toBe('rgb(7, 8, 9)'); + expect(rowOf('Beta').style.backgroundColor).toBe(''); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index eb981eba73..bbd04c82f6 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -265,7 +265,7 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ { name: 'reorderableColumns', type: 'boolean', description: 'Let users drag columns into a different order.' }, { name: 'showColumnTypeIcons', type: 'boolean', description: 'Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels.' }, { name: 'rowColor', type: 'object', description: 'Rules that colour whole rows from a field value.' }, - { name: 'conditionalFormatting', type: 'array', description: 'Row/cell styling rules. Accepts both the ObjectUI `{ field, operator, value }` form and the spec expression form `{ condition, style }`.' }, + { name: 'conditionalFormatting', type: 'array', description: 'Row style rules, each `{ condition, style }` — a CEL `condition` over the row’s own `record.*` and a CSS `style` map, the rule a list view declares. The first matching rule styles that row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` instead of inside `style` are retired (objectui#11533).' }, // ── grouping and roll-ups ───────────────────────────────────────────────── { name: 'grouping', type: 'object', description: 'Group rows by one or more fields into collapsible sections.' }, { name: 'aggregations', type: 'array', description: 'Per-group roll-ups shown in group headers, `[{ field, type: "sum" | "count" | "avg" | "min" | "max" | "count_distinct" }]`. Needs `grouping` to have anything to roll up.' }, diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index dec3b40112..ac7fdf647d 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -160,9 +160,10 @@ export interface KanbanBoardProps { // 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. 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. +// rule only (objectui#11522), as the grid and the list view do since +// objectui#11533; the shared evaluator still reads the native and colour-key +// arms as a compatibility read for rules STORED in that dialect — 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-list/src/__tests__/ListView.storedRuleDialect-11533.test.tsx b/packages/plugin-list/src/__tests__/ListView.storedRuleDialect-11533.test.tsx new file mode 100644 index 0000000000..4097b2f0ab --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.storedRuleDialect-11533.test.tsx @@ -0,0 +1,108 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A list view STORED with a rule in the retired native dialect still reaches its + * grid (objectui#11533). + * + * objectui#11533 retired the native `{ field, operator, value }` rule, its + * `expression` and the top-level colour keys from the list view's (and the + * grid's) AUTHORING faces: `ListViewSchema` refuses each by name. Stored stock + * is not authoring. A view saved before the retirement keeps its rules, nothing + * on the render path parses a stored node, and `ListView` hands the rules to the + * `object-grid` it builds exactly as stored. The grid's shared resolver keeps + * every arm as a compatibility read, so the row still paints — that half is + * pinned at the real grid in `@object-ui/plugin-grid`'s + * `gridRowDecorationMembers-8071.test.tsx`. + * + * What is pinned here is the HANDOFF, read off a probe registered as + * `object-grid`, beside the authoring refusal of the same document: refused at + * authoring, relayed untouched at render. The control is the + * `{ condition, style }` respelling, accepted and relayed the same way. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListViewSchema } from '@object-ui/types/zod'; +import { ListView } from '../ListView'; + +const OBJECT = 'task'; + +/** The native rule, exactly as a list view stored before objectui#11533 carries it. */ +const STORED_RULES = [{ field: 'status', operator: 'equals', value: 'late', backgroundColor: '#fee2e2' }]; + +/** Its `{ condition, style }` respelling, the one dialect the authoring faces take. */ +const RESPELLED_RULES = [{ condition: "record.status == 'late'", style: { backgroundColor: '#fee2e2' } }]; + +/** Records every props bag `SchemaRenderer` hands the `object-grid` node. */ +const GridProbe = vi.fn((_props: Record) =>
); + +beforeEach(() => { + GridProbe.mockClear(); + ComponentRegistry.register('object-grid', GridProbe as never); +}); + +afterEach(() => { + ComponentRegistry.unregister?.('object-grid'); + cleanup(); +}); + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [{ id: 't1', name: 'Ship it', status: 'late' }], total: 1, hasMore: false })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => ({ + name, + fields: { id: { name: 'id', type: 'text' }, name: { name: 'name', type: 'text' }, status: { name: 'status', type: 'text' } }, + })), + } as never; +} + +const storedView = (rules: unknown[]) => ({ + type: 'list-view', + objectName: OBJECT, + viewType: 'grid', + columns: ['name', 'status'], + conditionalFormatting: rules, +}); + +/** The rules the grid probe was handed, read off the node `ListView` built. */ +async function rulesHandedToTheGrid(rules: unknown[]): Promise { + const ds = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(GridProbe).toHaveBeenCalled()); + const props = GridProbe.mock.calls.at(-1)![0] as { schema?: { conditionalFormatting?: unknown } }; + return props.schema?.conditionalFormatting; +} + +describe('a list view STORED with a retired rule dialect (objectui#11533)', () => { + it('is refused BY NAME on the authoring face, and its rules still reach the grid untouched', async () => { + const parsed = ListViewSchema.safeParse(storedView(STORED_RULES)); + expect(parsed.success).toBe(false); + const atField = parsed.error!.issues.find((i) => i.path.join('.') === 'conditionalFormatting.0.field'); + expect(atField?.message).toContain('RETIRED (objectui#11533)'); + + // Render is not a validation door: the stored rules ride the grid node as + // stored, for the grid's resolver to paint. + expect(await rulesHandedToTheGrid(STORED_RULES)).toEqual(STORED_RULES); + }); + + it('CONTROL — the `{ condition, style }` respelling is accepted and relayed the same way', async () => { + expect(ListViewSchema.safeParse(storedView(RESPELLED_RULES)).success).toBe(true); + expect(await rulesHandedToTheGrid(RESPELLED_RULES)).toEqual(RESPELLED_RULES); + }); +}); diff --git a/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx b/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx index ecbf15571b..24aa7fabf3 100644 --- a/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx +++ b/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx @@ -438,7 +438,7 @@ const SENTINELS: Record = { collapseAllByDefault: true, color: '#112233', compactToolbar: true, - conditionalFormatting: [{ field: 'stage', operator: 'eq', value: 'won', color: '#ff0000' }], + conditionalFormatting: [{ condition: "record.stage == 'won'", style: { color: '#ff0000' } }], emptyState: { title: 'Nothing here yet' }, fieldTextColor: { name: '#0000ff' }, hiddenFields: ['secret'], diff --git a/packages/types/src/__tests__/export-options-spec-parity.test.ts b/packages/types/src/__tests__/export-options-spec-parity.test.ts index fa02dd681e..804c1cadc6 100644 --- a/packages/types/src/__tests__/export-options-spec-parity.test.ts +++ b/packages/types/src/__tests__/export-options-spec-parity.test.ts @@ -328,10 +328,11 @@ describe('the zod mirror binds exportOptions to the spec field (objectui#6956)', it('control: neighbouring members keep their accept sets', () => { // `conditionalFormatting` sits next to `exportOptions` in the same - // `.extend()` block and is untouched by objectui#6956: both of its shapes - // still parse and a non-array is still refused. + // `.extend()` block and is untouched by objectui#6956: its `{ condition, style }` + // rule still parses and a non-array is still refused. (The native + // `{ field, operator, value }` shape it also took then is retired since + // objectui#11533 and pinned in `grid-list-view-conditional-formatting-11533.test.ts`.) const cf = MirrorListViewSchema.shape.conditionalFormatting; - expect(cf.safeParse([{ field: 'status', operator: 'equals', value: 'open' }]).success).toBe(true); expect(cf.safeParse([{ condition: '${record.amount > 100}', style: { color: 'red' } }]).success).toBe(true); expect(cf.safeParse('nope').success).toBe(false); // `allowExport`, the local boolean the export menu is gated on. diff --git a/packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts b/packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts new file mode 100644 index 0000000000..eafa11e3ba --- /dev/null +++ b/packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts @@ -0,0 +1,299 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `conditionalFormatting` on `object-grid` (`ObjectGridSchema`, and the + * `object-view` `table` slot built from it) and on `list-view` speaks ONE rule + * dialect — the spec list view's `{ condition, style }` — and refuses the native + * dialect BY NAME (objectui#11533; triage ruling 5965301211: retire, as + * objectui#11522 ruled for `object-kanban`, no alias window). + * + * The retired spellings, each refused at its OWN key: + * + * - the native comparison `{ field, operator, value }`, + * - its template predicate `expression`, + * - a colour at the TOP LEVEL of a rule — `backgroundColor`, `borderColor`, + * `textColor` — on the native rule, beside an `expression`, or beside a CEL + * `condition` (the "flat CEL" rule) instead of inside `style`. + * + * Each refusal is read on every zod face an author meets: the two mirrors + * (`ObjectGridSchema`, `ListViewSchema`), the tolerant document face + * (`safeValidateSchema`) and the strict authoring face + * (`StrictAnyComponentSchema`), the last two on a `list-view` node and on an + * `object-view` `table` slot. A bare `success: false` would not be a refusal pin + * (a document with an unrelated mistake fails too), so each assertion reads the + * issue at the retired key's path and its message. The accepted + * `{ condition, style }` rule at index 0 of the same document is the live + * control on every face. + * + * ⚠️ The authored `object-grid` NODE is not one of these faces: its props are + * the `properties` bag, which is `@objectstack/spec`'s + * `ComponentPropsMap['object-grid']` row by reference, and the installed row + * types `conditionalFormatting` as `unknown`. It is the spec's member, not + * objectui's rule, so it is not narrowed here. + * + * Stored stock is not authoring: the shared resolver keeps every arm, so a grid + * or list view STORED with a retired rule still paints. That half is pinned at + * the renderer, in `@object-ui/plugin-grid`'s + * `gridRowDecorationMembers-8071.test.tsx` and `@object-ui/plugin-list`'s + * `ListView.storedRuleDialect-11533.test.tsx`. + * + * 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, expectTypeOf } from 'vitest'; +import type { z } from 'zod'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { + ListViewSchema, + ObjectGridSchema, + safeValidateSchema, + StrictAnyComponentSchema, +} from '../zod/index.zod'; +import { KanbanConditionalFormattingRuleSchema } from '../zod/objectql.zod'; +import type { + ConditionalFormattingRule, + ListViewSchema as ListViewSchemaType, + NamedListView, + ObjectGridSchema as ObjectGridSchemaType, + SpecConditionalFormattingRule, +} from '../objectql'; + +/** 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, as the grid and the list view used to accept it. */ +const NATIVE_RULE = { field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }; + +/** The retired template predicate, beside a colour. */ +const EXPRESSION_RULE = { expression: '${record.amount > 1000}', 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; keys?: string[] }; +type Parsed = { success: boolean; error?: { issues: Issue[] } }; + +/** + * Each face: how to wrap a rule list into the document it judges, the path + * prefix of the member inside that document, and the parse. + */ +type Face = readonly [ + label: string, + wrap: (rules: unknown[]) => unknown, + prefix: string, + parse: (doc: unknown) => Parsed, +]; + +const grid = (rules: unknown[]) => ({ type: 'object-grid', objectName: 'task', conditionalFormatting: rules }); +const listView = (rules: unknown[]) => ({ type: 'list-view', objectName: 'task', conditionalFormatting: rules }); +const objectView = (rules: unknown[]) => ({ type: 'object-view', objectName: 'task', table: { conditionalFormatting: rules } }); + +const FACES: readonly Face[] = [ + ['ObjectGridSchema (the grid mirror)', grid, 'conditionalFormatting', (d) => ObjectGridSchema.safeParse(d) as never], + ['ListViewSchema (the list-view mirror)', listView, 'conditionalFormatting', (d) => ListViewSchema.safeParse(d) as never], + ['safeValidateSchema on a `list-view` node', listView, 'conditionalFormatting', (d) => safeValidateSchema(d) as never], + ['StrictAnyComponentSchema on a `list-view` node', listView, 'conditionalFormatting', (d) => StrictAnyComponentSchema.safeParse(d) as never], + ['safeValidateSchema on an `object-view` `table` slot', objectView, 'table.conditionalFormatting', (d) => safeValidateSchema(d) as never], + ['StrictAnyComponentSchema on an `object-view` `table` slot', objectView, 'table.conditionalFormatting', (d) => StrictAnyComponentSchema.safeParse(d) as never], +]; + +/** The issue a face reports at `[index].key`, if any. */ +function issueAt(face: Face, rules: unknown[], index: number, key: string): Issue | undefined { + const [, wrap, prefix, parse] = face; + return parse(wrap(rules)).error?.issues.find((i) => i.path.join('.') === `${prefix}.${index}.${key}`); +} + +/** What every retirement message must carry: the key, 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!.code).toBe('invalid_type'); + expect(issue!.message.startsWith(`\`${key}\``), issue!.message).toBe(true); + expect(issue!.message).toContain('RETIRED (objectui#11533)'); + expect(issue!.message).toContain('`{ condition, style }`'); +} + +describe.each(FACES)('objectui#11533 — `conditionalFormatting` on %s', (...face) => { + const [, wrap, prefix, parse] = face; + + it('CONTROL — the spec `{ condition, style }` rule is accepted, with a string or an envelope condition', () => { + expect(parse(wrap([SPEC_RULE])).success).toBe(true); + expect(parse(wrap([{ condition: { dialect: 'cel', source: "record.status == 'done'" }, style: { color: 'red' } }])).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]; + const parsed = parse(wrap(rules)); + expect(parsed.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 rule dialect'); + expect(issue!.message).toContain('`{ condition: "record.priority == \'high\'", style: { backgroundColor: \'#fee2e2\' } }`'); + } + expectRetirement(issueAt(face, rules, 1, 'backgroundColor'), 'backgroundColor'); + expect(parsed.error!.issues.filter((i) => i.path.join('.').startsWith(`${prefix}.0`))).toEqual([]); + }); + + it('refuses `expression` BY NAME, with the `condition` respelling', () => { + const rules = [SPEC_RULE, EXPRESSION_RULE]; + const issue = issueAt(face, rules, 1, 'expression'); + expectRetirement(issue, 'expression'); + expect(issue!.message).toContain('`{ condition: \'record.amount > 1000\', style: { backgroundColor: \'#fee2e2\' } }`'); + }); + + 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 it used to be: with one dialect left there + // is no union to fail, and no issue sits at the bare rule path. + const parsed = parse(wrap(rules)); + expect(parsed.error!.issues.some((i) => i.code === 'invalid_union' && i.path.join('.') === `${prefix}.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']) { + expectRetirement(issueAt(face, [{ ...SPEC_RULE, [key]: 'red' }], 0, key), key); + } + expect(issueAt(face, [{ ...SPEC_RULE, textColor: 'red' }], 0, 'textColor')!.message).toContain('`style: { color }`'); + }); + + it('inherits the spec rule\'s strictness: an undeclared key is refused with the spec\'s own message', () => { + const parsed = parse(wrap([{ ...SPEC_RULE, label: 'Done' }])); + expect(parsed.success).toBe(false); + const issue = parsed.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path.join('.')).toBe(`${prefix}.0`); + expect(issue?.keys).toEqual(['label']); + expect(issue?.message).toContain('Unrecognized key(s) on this conditional formatting rule'); + }); +}); + +describe('objectui#11533 — the grid\'s and the list view\'s rule IS the spec list view\'s, by reference', () => { + const specRule = SpecListViewSchema.shape.conditionalFormatting.unwrap().element; + type Shaped = { shape: Record }; + const gridRule = ObjectGridSchema.shape.conditionalFormatting.unwrap().element as unknown as Shaped; + const listRule = ListViewSchema.shape.conditionalFormatting.unwrap().element as unknown as Shaped; + + it('is ONE declaration, shared by the two mirrors', () => { + expect(gridRule).toBe(listRule); + }); + + it('`style` is the spec rule\'s own member (identity, not a copy)', () => { + expect(gridRule.shape.style).toBe(specRule.shape.style); + }); + + it('`condition` is objectui\'s string arm, then the spec slot BY REFERENCE — the arm the kanban rule reads too', () => { + type UnionOf = { options: readonly unknown[] }; + const condition = gridRule.shape.condition as UnionOf; + expect(condition.options[1]).toBe(specRule.shape.condition); + // The kanban rule's `condition` is the same union, re-described for the card + // (`.describe()` returns a copy, so the arms are compared, not the union). + const kanbanCondition = (KanbanConditionalFormattingRuleSchema as unknown as Shaped).shape.condition as UnionOf; + expect(kanbanCondition.options[1]).toBe(condition.options[1]); + expect(kanbanCondition.options[0]).toBe(condition.options[0]); + }); + + it('CONTROL — the identity checks can fail: `condition` is NOT the bare spec slot', () => { + expect(gridRule.shape.condition).not.toBe(specRule.shape.condition); + }); + + it('the spec rule alone refuses the same native rule — the strictness is the spec\'s, not a local copy', () => { + expect(specRule.safeParse(NATIVE_RULE).success).toBe(false); + expect(specRule.safeParse(SPEC_RULE).success).toBe(true); + }); +}); + +/* ── the TS face (tsc is the pin) ────────────────────────────────────────── */ + +const accepted: ConditionalFormattingRule = SPEC_RULE; + +const envelope: ConditionalFormattingRule = { + condition: { dialect: 'cel', source: "record.status == 'done'" }, + style: { borderColor: 'red' }, +}; + +const nativeRefused: ConditionalFormattingRule = { + // @ts-expect-error — `field` is `?: never` (objectui#11533): 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 expressionRefused: ConditionalFormattingRule = { + condition: 'record.amount > 1000', + style: {}, + // @ts-expect-error — `expression` is `?: never` (objectui#11533): write the predicate as `condition`. + expression: '${record.amount > 1000}', +}; + +const flatColourRefused: ConditionalFormattingRule = { + condition: "record.owner == 'bob'", + style: {}, + // @ts-expect-error — a top-level colour is `?: never` (objectui#11533): 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: ConditionalFormattingRule = { condition: "record.owner == 'bob'" }; + +// The members that carry the rule: the grid's, the list view's and the named view's. +const gridNode: ObjectGridSchemaType = { + type: 'object-grid', + objectName: 'task', + // @ts-expect-error — the grid's member takes `ConditionalFormattingRule`: the native rule is refused. + conditionalFormatting: [NATIVE_RULE], +}; + +const listNode: ListViewSchemaType = { + type: 'list-view', + objectName: 'task', + // @ts-expect-error — the list view's member is `z.input` of the narrowed zod rule. + conditionalFormatting: [NATIVE_RULE], +}; + +describe('objectui#11533 — 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, expressionRefused, flatColourRefused, flatCelMissingStyle, gridNode, listNode].length).toBe(6); + }); + + it('the two faces declare the same keys, and the rule is the spec-format rule plus the tombstones', () => { + type ZodIn = NonNullable['conditionalFormatting']>[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // Every retired key is unwritable on both faces (`?: never` / `z.never().optional()`). + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + // And the accepted rule is exactly the shared spec-format rule. + expectTypeOf().toMatchTypeOf(); + }); + + it('the grid\'s, the list view\'s and the named view\'s members all carry the one rule', () => { + type ListRule = NonNullable[number]; + expectTypeOf[number]>().toEqualTypeOf(); + expectTypeOf[number]>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf>().toBeNever(); + }); +}); 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 ff9eaef0d0..1ae3e103e5 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 @@ -101,8 +101,9 @@ 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 }>; - // One dialect since objectui#11522, so there is no union arm to extract. + // One dialect on both rules — the list view's since objectui#11533, the kanban + // board's since objectui#11522 — so there is no union arm to extract. + type MirrorRule = NonNullable['conditionalFormatting']>[number]; type KanbanMirrorRule = z.input; expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -170,9 +171,11 @@ describe('objectui#10946 — the envelope arm IS the spec\'s slot schema (refere type UnionOf = { options: readonly unknown[] }; type RuleArm = { shape: { condition: UnionOf } }; - it('on the list view\'s rule union', () => { - const rule = ListViewSchema.shape.conditionalFormatting.unwrap().element as unknown as UnionOf; - const condition = (rule.options[1] as RuleArm).shape.condition; + // objectui#11533 retired the list view's (and the grid's) native arm too, so + // that rule is ONE object now as well, and `condition` is read straight off + // its shape. + it('on the list view\'s rule', () => { + const condition = (ListViewSchema.shape.conditionalFormatting.unwrap().element as unknown as RuleArm).shape.condition; expect(condition.options[1]).toBe(specConditionSlot); }); diff --git a/packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts b/packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts index 2b734ac072..e47fc3c7f5 100644 --- a/packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts +++ b/packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts @@ -52,8 +52,13 @@ const MIRRORED: ReadonlyArray<{ key: string; valid: unknown; invalid: unknown; s }, { key: 'conditionalFormatting', - valid: [{ field: 'status', operator: 'equals', value: 'open', backgroundColor: '#fee2e2' }, { condition: "record.status == 'late'", style: { color: 'red' } }], - invalid: [{ field: 'status', operator: 'matches', value: 'open' }], + // One rule dialect since objectui#11533, the spec list view's `{ condition, style }`: + // the native `{ field, operator, value }` row this used to carry is refused by name + // now (`grid-list-view-conditional-formatting-11533.test.ts`), and its wrong value — + // an operator outside the native enum — has no member left to be wrong in. The + // wrong value is a `style` written as a CSS string instead of a map. + valid: [{ condition: "record.status == 'open'", style: { backgroundColor: '#fee2e2' } }, { condition: "record.status == 'late'", style: { color: 'red' } }], + invalid: [{ condition: "record.status == 'late'", style: 'color: red' }], slot: true, }, { key: 'grouping', valid: { fields: [{ field: 'region' }] }, invalid: { fields: [] }, slot: true }, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e3d3c6fe77..77c6a4e8a5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -482,8 +482,8 @@ export type { ListViewGalleryConfig, ListViewTimelineConfig, SortConfig, - // ConditionalFormatting dual-format types - ObjectUIConditionalFormattingRule, + // ConditionalFormatting: the spec rule, and the grid / list-view rule built on it + // (one dialect since objectui#11533; the native rule's interface is gone) SpecConditionalFormattingRule, ConditionalFormattingRule, // Component schemas diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 616d312d59..d5c1398d36 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -410,30 +410,9 @@ export interface SortConfig { // ============================================================================ -// ConditionalFormatting Types — Dual-format support +// ConditionalFormatting Types — ONE dialect, the spec list view's { condition, style } // ============================================================================ -/** - * ObjectUI-native ConditionalFormatting rule. - * Uses field/operator/value for declarative comparisons. - */ -export interface ObjectUIConditionalFormattingRule { - /** Field name to evaluate */ - field: string; - /** Comparison operator */ - operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in'; - /** Value to compare against */ - value: unknown; - /** CSS-compatible background color */ - backgroundColor?: string; - /** CSS-compatible text color */ - textColor?: string; - /** CSS-compatible border color */ - borderColor?: string; - /** Template expression override (e.g., '${data.amount > 1000}') */ - expression?: string; -} - /** * One entry of a named view's `conditionalFormatting`, as the protocol declares * it: `@objectstack/spec/ui`'s `ObjectListViewSchema`, read on its authoring @@ -481,10 +460,56 @@ export interface SpecConditionalFormattingRule { } /** - * Union type for ConditionalFormatting rules — accepts both ObjectUI and Spec formats. - * Rules are evaluated in order; first matching rule wins. + * One `conditionalFormatting` rule on `object-grid` ({@link ObjectGridSchema}) and + * `list-view` — ONE dialect, the spec list view's `{ condition, style }`: a CEL + * `condition` over the row's `record.*` and a CSS `style` map, first matching rule + * wins (objectui#11533). + * + * 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 }` and its template predicate + * `expression` (the `ObjectUIConditionalFormattingRule` interface that declared + * them is gone), and + * - a colour written at the TOP LEVEL of a rule — `backgroundColor`, `borderColor`, + * `textColor`. A colour belongs inside `style` (`textColor` is `style.color`). + * + * Retired, not widened, with no alias window (triage ruling 5965301211 on + * objectui#11533, as objectui#11522 ruled for {@link KanbanConditionalFormattingRule}). + * `@objectstack/spec` declares the list view's member as this rule and types the + * `object-grid` row's member by reference to it. + * + * 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` types its own input (`ConditionalFormattingRuleLike`) + * and still reads every arm, as a compatibility read for rules already STORED in the + * native dialect; nothing on the render path parses a stored view against this type's + * zod twin, so such a view still paints. A `{ condition, style }` rule paints exactly + * as it did before. + * + * The zod twin is the module-private `ConditionalFormattingRuleSchema` in + * `./zod/objectql.zod.ts`, each retired key a `retirementTombstone()` there. */ -export type ConditionalFormattingRule = ObjectUIConditionalFormattingRule | SpecConditionalFormattingRule; +export interface ConditionalFormattingRule extends SpecConditionalFormattingRule { + /** @deprecated RETIRED (objectui#11533) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + field?: never; + /** @deprecated RETIRED (objectui#11533) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + operator?: never; + /** @deprecated RETIRED (objectui#11533) — the native rule dialect. Write `condition` as CEL over `record.*`. */ + value?: never; + /** @deprecated RETIRED (objectui#11533) — the native template predicate. Write it as `condition`, CEL over `record.*`. */ + expression?: never; + /** @deprecated RETIRED (objectui#11533) — a top-level colour. Write `style: { backgroundColor }`. */ + backgroundColor?: never; + /** @deprecated RETIRED (objectui#11533) — a top-level colour. Write `style: { borderColor }`. */ + borderColor?: never; + /** @deprecated RETIRED (objectui#11533) — a top-level colour. Write `style: { color }`. */ + textColor?: never; +} /** * Parameter declaration for a bulk action. Rendered as a single field in the @@ -1344,9 +1369,11 @@ export interface ObjectGridSchema extends BaseSchema { onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void; /** - * Conditional formatting rules for row/cell styling. - * Aligned with @objectstack/spec ListViewSchema.conditionalFormatting. - * Supports both ObjectUI field/operator/value rules and Spec expression-based { condition, style } rules. + * Conditional formatting rules for row styling — `[{ condition, style }]`, the rule + * @objectstack/spec's `ListViewSchema.conditionalFormatting` declares: the first rule + * whose CEL `condition` holds applies its CSS `style` map to the row. The native + * `{ field, operator, value }` rule, its `expression` and a top-level colour are + * retired and refused by name (objectui#11533); see {@link ConditionalFormattingRule}. */ conditionalFormatting?: ConditionalFormattingRule[]; @@ -2858,8 +2885,8 @@ export interface NamedListView { formView?: string; }; - /** Conditional formatting rules. - * Supports both ObjectUI field/operator/value rules and Spec expression-based { condition, style } rules. */ + /** Conditional formatting rules — `{ condition, style }` only since objectui#11533 + * (see {@link ConditionalFormattingRule}); the native rule dialect is refused by name. */ conditionalFormatting?: ConditionalFormattingRule[]; /** @@ -3145,9 +3172,12 @@ export type RecordNavigateAction = 'view' | 'new_window'; * zod/JSON-schema. * * Legacy objectui vocabulary (`viewType`/`fields`/`filters`/`show*`/`densityMode`/…) and - * the broader-than-spec configs (`userFilters`/`sharing`/`aria`/`conditionalFormatting`/ + * the broader-than-spec configs (`userFilters`/`sharing`/`aria`/ * `exportOptions`/`kanban`/`calendar`/`gantt`/`gallery`/`timeline`) remain as sanctioned * local `.extend()`s on the schema; migration to the spec-canonical keys is deferred (#2231). + * `conditionalFormatting` is a local `.extend()` too but no longer broader than spec: it is + * the spec list view's own `{ condition, style }` rule, the native dialect refused by name + * (objectui#11533, {@link ConditionalFormattingRule}). */ export type ListViewSchema = ListViewAuthored & ListViewRuntimeProps; @@ -5100,10 +5130,11 @@ export interface ObjectKanbanSchema extends BaseSchema { * 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. + * `resolveConditionalFormatting` still reads every arm, as a compatibility read + * for rules already STORED in the native dialect — since objectui#11533 the + * grid's and the list view's {@link ConditionalFormattingRule} retires them on + * the authoring face too; 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. diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 0c9641b6d8..bfee9cdc28 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -19,8 +19,13 @@ This directory contains runtime validation schemas using [Zod](https://github.co Only the component envelope (`type: 'list-view'` + `objectName`), the legacy objectui vocabulary (`viewType`/`fields`/`filters`/`show*`/`densityMode`/…), and the handful of configs whose objectui shape is intentionally broader than spec's (`userFilters`, -`sharing`, `aria`, `conditionalFormatting`, and the per-view-type -`kanban`/`calendar`/`gantt`/`gallery`/`timeline`) are declared locally on top. The TS type +`sharing`, `aria`, and the per-view-type +`kanban`/`calendar`/`gantt`/`gallery`/`timeline`) are declared locally on top. +`conditionalFormatting` is declared locally too, but it is no longer broader than spec +(objectui#11533): it is the spec list view's own `{ condition, style }` rule, by +reference, with objectui's string `condition` arm and the retired native dialect +(`field`/`operator`/`value`, `expression`, top-level colours) refused by name. The grid's +`ObjectGridSchema` shares the same rule. The TS type is `z.infer & ListViewRuntimeProps`. A drift-guard test (`__tests__/list-view-spec-parity.test.ts`) fails if the spec grows a field objectui hasn't triaged. **Do not hand-add spec-owned fields here** — import them from the spec. diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 4813c9f33a..8af435c095 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -321,11 +321,22 @@ const OBJECT_NAME_BINDING_WAIVER_DESCRIPTION = + '`ElementDataSourceGate` lands here before the renderer reads the node. A node with neither is refused ' + 'here, keyed `RECORD_SOURCE_REQUIRED`.'; +/** + * The protocol's own conditional-formatting rule: `ListViewSchema.conditionalFormatting[]`'s + * element, read BY REFERENCE through the import boundary — a strict `{ condition, style }` + * object. Both rules this module declares `.extend()` it: `ConditionalFormattingRuleSchema` + * below (the grid's and the list view's, objectui#11533) and + * `KanbanConditionalFormattingRuleSchema` (objectui#11522). Declared here, above the grid's + * mirror, because that mirror reads it at module load (objectui#6152 round 6). The boundary + * walker is memoised, so every read of this element is the same object. + */ +const SpecListViewRuleSchema = stripImportedDefaults(SpecListViewSchema).shape.conditionalFormatting.unwrap().element; + /** * The `condition` of a spec-shape conditional-formatting rule, `{ condition, style }`: * the zod twin of `SpecConditionalFormattingRule.condition` (`../objectql.ts`), read by - * both rule unions in this module (`ConditionalFormattingRuleSchema` right below, which - * the grid's and the list view's `conditionalFormatting` share, and + * both rules in this module (`ConditionalFormattingRuleSchema` right below, which the + * grid's and the list view's `conditionalFormatting` share, and * `KanbanConditionalFormattingRuleSchema`), objectui#10946. It sits above the grid's * mirror because that mirror reads it at module load (objectui#6152 round 6). * @@ -347,32 +358,98 @@ const OBJECT_NAME_BINDING_WAIVER_DESCRIPTION = */ const SpecRuleConditionSchema = z.union([ z.string(), - stripImportedDefaults(SpecListViewSchema).shape.conditionalFormatting.unwrap().element.shape.condition, + SpecListViewRuleSchema.shape.condition, ]); /** - * One `conditionalFormatting` rule in either dialect `ConditionalFormattingRule` - * (`../objectql.ts`) declares: the objectui-native `{ field, operator, value, … }` - * comparison, or the spec-shape `{ condition, style }` rule. Module-private and - * shared, so the list view's member and the grid's member are one declaration: - * objectui#6152 round 6 hoisted it, unchanged, out of `ListViewSchema` when the - * grid's mirror took the same member. + * The guidance a retired `object-grid` / `list-view` rule key is refused with + * (objectui#11533), as `kanbanRuleKeyRetired` below does for `object-kanban` + * (objectui#11522). + * + * `native` keys are the native `{ field, operator, value }` comparison; + * `expression` is that dialect's template predicate; `colour` keys are a colour + * written at the TOP LEVEL of a rule — on the native comparison, beside an + * `expression`, or beside a CEL `condition` (the "flat CEL" rule). Each message + * names the key, the retirement and the one spelling that replaces it. */ -const ConditionalFormattingRuleSchema = z.union([ - z.object({ - field: z.string(), - operator: z.enum(['equals', 'not_equals', 'contains', 'greater_than', 'less_than', 'in']), - value: z.any(), - backgroundColor: z.string().optional(), - textColor: z.string().optional(), - borderColor: z.string().optional(), - expression: z.string().optional(), - }), - z.object({ - condition: SpecRuleConditionSchema, - style: z.record(z.string(), z.string()), - }), -]); +function gridRuleKeyRetired(key: string, kind: 'native' | 'expression' | 'colour'): string { + const lead = + kind === 'native' + ? `\`${key}\` belongs to the native rule dialect \`{ field, operator, value }\`, ` + : kind === 'expression' + ? '`expression` is the template predicate of the native rule dialect, ' + : `\`${key}\` is a colour written at the top level of the rule, `; + const into = key === 'textColor' ? '`style: { color }`' : `\`style: { ${key} }\``; + const respell = + kind === 'native' + ? 'Respell `{ field: \'priority\', operator: \'equals\', value: \'high\', backgroundColor: \'#fee2e2\' }` as ' + + '`{ condition: "record.priority == \'high\'", style: { backgroundColor: \'#fee2e2\' } }` ' + + '(`not_equals` is `!=`, `greater_than` is `>`, `less_than` is `<`, `contains` is `.contains(…)`, ' + + '`in` is `record.f in [ … ]`).' + : kind === 'expression' + ? 'Write the predicate as the rule\'s `condition`, in CEL over `record.*` with no `${…}` wrapper: ' + + '`{ expression: \'${record.amount > 1000}\', backgroundColor: \'#fee2e2\' }` is ' + + '`{ condition: \'record.amount > 1000\', style: { backgroundColor: \'#fee2e2\' } }`.' + : `Move the colour into the rule's CSS map: ${into}.`; + return ( + lead + + 'which `conditionalFormatting` on `object-grid` and `list-view` no longer accepts: RETIRED (objectui#11533), ' + + '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. ' + + respell + ); +} + +/** + * objectui#11533 — ONE `conditionalFormatting` rule dialect for the grid and the + * list view, the spec list view's `{ condition, style }`; the native dialect is + * refused BY NAME (triage ruling 5965301211: retire, as objectui#11522 ruled for + * the kanban board, not widen). + * + * Module-private and shared, so the list view's member and the grid's member are + * one declaration (objectui#6152 round 6 hoisted it out of `ListViewSchema` when + * the grid's mirror took the same member). Through the grid's mirror it also + * judges the `object-view` `table` slot. + * + * The rule is the protocol's own element ({@link SpecListViewRuleSchema}), + * `.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`, so a string condition is not + * canonicalized into an envelope and `''` is still accepted, exactly as + * before (objectui#10946); + * - 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`, + * its template predicate `expression`, and the three top-level colour keys + * the shared resolver would otherwise still paint with — `backgroundColor`, + * `borderColor`, `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 + * (`{ field, operator, value, backgroundColor?, textColor?, borderColor?, + * expression? }`) and a loose `{ condition, style }` object, under a "broader + * than spec, migration deferred" note on the list view. + * + * ⚠️ Stored stock is not authoring: `resolveConditionalFormatting` + * (`@object-ui/core`) keeps every arm as a compatibility read, so a grid or list + * view STORED with a native rule still paints — nothing on the render path parses + * against this schema. The TS twin is `ConditionalFormattingRule` + * (`../objectql.ts`); the two faces are pinned equal in + * `../__tests__/grid-list-view-conditional-formatting-11533.test.ts`. + */ +const ConditionalFormattingRuleSchema = SpecListViewRuleSchema.extend({ + condition: SpecRuleConditionSchema, + field: retirementTombstone(gridRuleKeyRetired('field', 'native')), + operator: retirementTombstone(gridRuleKeyRetired('operator', 'native')), + value: retirementTombstone(gridRuleKeyRetired('value', 'native')), + expression: retirementTombstone(gridRuleKeyRetired('expression', 'expression')), + backgroundColor: retirementTombstone(gridRuleKeyRetired('backgroundColor', 'colour')), + borderColor: retirementTombstone(gridRuleKeyRetired('borderColor', 'colour')), + textColor: retirementTombstone(gridRuleKeyRetired('textColor', 'colour')), +}); /** * objectui#6152 round 6 — one entry of `ObjectGridSchema.aggregations`, restating @@ -645,12 +722,14 @@ export const ObjectGridSchema = BaseSchema.extend({ // the source of the `object-view` `table` slot, so each member takes the type the // twin declares. Where the twin takes a spec schema by name, the member is that // schema BY REFERENCE (`grouping`, `navigation`, `rowColor`, `rowHeight`) or the - // row's own member (`reorderableColumns`, `singleClickEdit`); the rest restate the + // row's own member (`reorderableColumns`, `singleClickEdit`), and + // `conditionalFormatting` is the spec list view's own rule BY REFERENCE (the shared + // `ConditionalFormattingRuleSchema` above, objectui#11533); the rest restate the // twin's local shapes. ⛔ `resizableColumns`, the eleventh, is NOT here: its route // is open on objectui#6152 (the entry in `zod-mirror-parity.test.ts` says why). aggregations: z.array(ObjectGridAggregationSchema).optional().describe('Per-group aggregations drawn in each group header, e.g. [{ field: "amount", type: "sum" }]'), bulkActionDefs: z.array(ObjectGridBulkActionDefSchema).optional().describe('Rich bulk action definitions; each opens the bulk action dialog (params, confirm, progress) for the selected rows'), - conditionalFormatting: z.array(ConditionalFormattingRuleSchema).optional().describe('Conditional formatting rules for row styling: { field, operator, value, … } or { condition, style }'), + conditionalFormatting: z.array(ConditionalFormattingRuleSchema).optional().describe('Conditional formatting rules for row styling — `[{ condition, style }]`, the rules a list view declares: the first rule whose CEL `condition` holds applies its CSS `style` map to the row'), grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Row grouping: the spec GroupingConfig, by reference'), navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Row-click navigation: the spec NavigationConfig, by reference'), operations: ObjectGridOperationsSchema.optional().describe('Built-in operation toggles { create, read, update, delete, export, import }; a declared block replaces the default'), @@ -1317,8 +1396,10 @@ const UserFiltersSchema = z.object({ * - legacy vocabulary kept for back-compat: `viewType` (renamed spec `type`), * `fields`/`columns`, `filters`, the `show*` toolbar flags, `densityMode`, `color`, …; * - configs whose objectui shape is intentionally broader than spec's (migration - * deferred): `userFilters`, `sharing`, `aria`, `conditionalFormatting` - * (`exportOptions` left this list with objectui#6956 — it is the spec field by reference). + * deferred): `userFilters`, `sharing`, `aria` + * (`exportOptions` left this list with objectui#6956 — it is the spec field by reference; + * `conditionalFormatting` left it with objectui#11533 — it is the spec list view's own + * `{ condition, style }` rule by reference, with the native dialect refused by name). * * The per-view-type configs (`kanban`/`calendar`/`gantt`/`gallery`/`timeline`) are no * longer forks: they derive from the spec configs below, keeping only `calendar.defaultView` @@ -2098,7 +2179,11 @@ export const ListViewSchema = BaseSchema live: z.enum(['polite', 'assertive', 'off']).optional() .describe('aria-live politeness for the list region (objectui-only — promote rather than grow this extension)'), }).optional().describe('ARIA attributes'), - // The two-dialect rule, shared with `ObjectGridSchema` (objectui#6152 round 6). + // NOT broader than spec since objectui#11533: the spec list view's own + // `{ condition, style }` rule, by reference, with the native dialect's keys + // refused by name — the one rule declaration `ObjectGridSchema` shares + // (objectui#6152 round 6). Still a local override because the rule keeps + // objectui's string `condition` arm and names the retired keys. conditionalFormatting: z.array(ConditionalFormattingRuleSchema).optional().describe('Conditional formatting rules'), // `exportOptions` — the spec's own field, BY REFERENCE (objectui#6956). // @@ -2958,7 +3043,7 @@ function kanbanRuleKeyRetired(key: string, kind: 'native' | 'colour'): string { // 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. +// exactly as the list view's `{ condition, style }` rule 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` / @@ -2970,14 +3055,16 @@ function kanbanRuleKeyRetired(key: string, kind: 'native' | 'colour'): string { // // 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 +// arm. ⚠️ The shared resolver is NOT narrowed: since objectui#11533 no authored +// rule declares the native arms (the grid's and the list view's rule, +// `ConditionalFormattingRuleSchema` above, retired them too), but a rule STORED +// in that dialect still reaches the resolver, which keeps every arm as a +// compatibility read — 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; - +// pinned equal in `../__tests__/kanban-conditional-formatting.test.ts`. The +// element it extends, `SpecListViewRuleSchema`, is declared above the grid's +// mirror, which reads it first. export const KanbanConditionalFormattingRuleSchema = SpecListViewRuleSchema.extend({ condition: SpecRuleConditionSchema.describe('CEL predicate evaluated against the card record'), field: retirementTombstone(kanbanRuleKeyRetired('field', 'native')),