diff --git a/.changeset/22228-field-conditional-formatting.md b/.changeset/22228-field-conditional-formatting.md new file mode 100644 index 00000000000..b136e4e9e29 --- /dev/null +++ b/.changeset/22228-field-conditional-formatting.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/metadata-core': patch +'@objectstack/driver-sql': patch +--- + +A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + +Clause-②: yes (widening) + +- **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. +- **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. +- **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). +- **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). +- **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). +- **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index f653e7946d4..0eba1edbec4 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -391,6 +391,7 @@ For values that must be encrypted at rest (API keys, tokens, DB passwords), use | `readonlyWhen` | `string \| Expression` | CEL predicate; field is read-only when `TRUE` | | `requiredWhen` | `string \| Expression` | CEL predicate; field is required when `TRUE` | | `settledWhen` | `string \| Expression` | CEL predicate on a `dueLike` date; the deadline is settled when `TRUE` — see [Deadlines](#deadlines-duelike-and-settledwhen) | +| `conditionalFormatting` | `{ condition, style }[]` | Cell formatting rules; the first rule whose CEL `condition` is `TRUE` styles this field's cell — see [Cell formatting](#cell-formatting-conditionalformatting) | | `multiple` | `boolean` | Allow multiple values (for select, lookup, file) | ```typescript @@ -490,6 +491,50 @@ Two rules make this predictable: lock the platform could not evaluate is never waived: that is what makes the declaration a guarantee instead of a hint. +#### Cell formatting: `conditionalFormatting` + +`conditionalFormatting` styles a field's **cell** by a rule: an ordered list of +`{ condition, style }`, the same rule a list view's `conditionalFormatting` +declares for its rows. The first rule whose CEL `condition` is `TRUE` applies +its CSS `style` map to this field's cell wherever the field renders — a grid, a +kanban card, the record page, a related list or a report cell. A list view's +row rules style the row, and both may apply to one record. + +The condition reads two roots: `value`, this field's value on the record, and +`record`, the row. + +```typescript +import { P } from '@objectstack/spec'; +import { Field } from '@objectstack/spec/data'; + +amount: Field.currency({ + label: 'Amount', + conditionalFormatting: [ + { condition: P`value < 0`, style: { color: '#b91c1c' } }, + ], +}), +``` + +The rules are checked when the metadata is authored, like `visibleWhen`: + +- A rule is exactly `{ condition, style }`, the condition is not blank, and + `style` maps CSS property names to string values. Anything else is refused at + parse. +- The condition must parse, read other fields as `record.`, name fields + the object declares, and read no root but `value` and `record`. + `objectstack validate`, `objectstack build` and saving the object all refuse + anything else, naming the field and the rule. + +The rules are for presentation: an amount below zero in red, stock under its +safety level in amber, a priority cell coloured by its value. A state with a +meaning of its own belongs in a declaration instead — a deadline's overdue +state is [`dueLike` with `settledWhen`](#deadlines-duelike-and-settledwhen), +from which the wording and the colour both follow. Nothing on the write path +reads the rules. + +The console's cell renderers read these rules in a later ObjectUI release. +Until then a rule is validated when it is authored and styles no cell. + ## Naming Conventions - Field names use `snake_case`: `first_name`, `annual_revenue`, `is_active` diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index ee24eb2fada..2122168cefb 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -109,6 +109,7 @@ const result = CurrencyConfigSchema.parse(data); | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | @@ -241,6 +242,13 @@ const result = CurrencyConfigSchema.parse(data); | **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | | **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | +### Nested Shape: `Field.conditionalFormatting[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL) to evaluate. | +| **style** | `Record` | ✅ | CSS styles to apply when condition is true | + ### Nested Shape: `Field.maskingRule` | Property | Type | Required | Description | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e91c5c8fcef..f6ff2b8f5ba 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -274,6 +274,7 @@ const result = ApiMethod.parse(data); | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | @@ -611,6 +612,7 @@ const result = ApiMethod.parse(data); | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 336f7d5a787..705ef7509f8 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -110,6 +110,7 @@ Add a new field to an existing object | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | @@ -535,6 +536,7 @@ Add a new field to an existing object | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md index b56db38469e..94d3e05194e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `shared/` | 20 | +| `shared/` | 21 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 7863d772481..bf901dffa21 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 210 | 199 | 4 | 0 | 7 | +| `ui/` | 209 | 198 | 4 | 0 | 7 | ## `ui/` — sites @@ -44,9 +44,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 60 | +| `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **210** | +| **total** | **209** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 210**, in 4 file(s). +**7 strip of 209**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 4 | 60 | +| `view.zod.ts` | 4 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **210** | +| **total** | **7** | **209** | | Bucket | Sites | |---|---| diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index bc85fac1dee..e90064c59d0 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -122,6 +122,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = conditionalRequired: 'presentation', dueLike: 'presentation', // declares a date a deadline (#22227) — read by the renderer's date cells, never by `createColumn`: the column is the date the type already sizes settledWhen: 'presentation', // the deadline's settle predicate (#22227), display only like `visibleWhen` — no DDL reads it + conditionalFormatting: 'presentation', // a field's cell formatting rules (#22228), display only — no DDL reads it requiredPermissions: 'presentation', maskingRule: 'presentation', ackPlaintextMasking: 'presentation', diff --git a/packages/lint/src/validate-expressions.field-conditional-formatting.test.ts b/packages/lint/src/validate-expressions.field-conditional-formatting.test.ts new file mode 100644 index 00000000000..194fa2b7c25 --- /dev/null +++ b/packages/lint/src/validate-expressions.field-conditional-formatting.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22228] A field's cell formatting rules — `FieldSchema.conditionalFormatting`, + * each `{ condition, style }`, the list view's own rule element — at the + * authoring gate. + * + * The CEL scope is `value` (this field's value on the record) and `record` + * (the row), by the card's ruling. The gate judges each condition with the + * `record`-scoped check, naming `value` as a root this surface binds, plus this + * surface's own root verdict (`fieldFormattingRootIssue`): it parses, it reads + * `record.` and never a bare field, the field exists, and it reads no + * root but those two. The parse-level half — the rule's shape, and that the + * element is the list view's — is pinned in the spec's + * `field-conditional-formatting.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { SCOPE_ROOTS } from '@objectstack/formula'; + +import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { + validateStackExpressions, + runStackExpressionPasses, + FIELD_FORMATTING_BOUND_ROOTS, + FIELD_RULE_JUDGED_ROOTS, + fieldFormattingRootIssue, +} from './validate-expressions.js'; +import type { ExprIssue } from './validate-expressions.js'; + +const RED = { color: '#b91c1c' }; + +/** One object whose `amount` field carries `rules`; `sharingModel` keeps the door's OWD rule quiet. */ +const fxObject = (rules: unknown, extra: Record = {}) => ({ + name: 'fx_invoice', + label: 'Invoice', + sharingModel: 'private', + fields: { + amount: { type: 'currency', label: 'Amount', conditionalFormatting: rules, ...extra }, + status: { + type: 'select', + label: 'Status', + options: [{ label: 'Open', value: 'open' }, { label: 'Paid', value: 'paid' }], + }, + safety_level: { type: 'number', label: 'Safety Level' }, + }, +}); +const stackWith = (rules: unknown, extra: Record = {}) => ({ objects: [fxObject(rules, extra)] }); + +const whereOf = (i: number) => `object 'fx_invoice' · field 'amount' conditionalFormatting[${i}].condition`; +const errorsOf = (issues: ExprIssue[]) => issues.filter((x) => (x.severity ?? 'error') === 'error'); +const at = (issues: ExprIssue[], i: number) => errorsOf(issues).filter((x) => x.where === whereOf(i)); +const dump = (r: unknown) => JSON.stringify(r, null, 2); + +describe('[#22228] field conditionalFormatting — the authoring gate', () => { + it('accepts a rule whose condition parses and reads `value` — the card\'s worked example', () => { + const issues = validateStackExpressions(stackWith([{ condition: 'value < 0', style: RED }])); + expect(errorsOf(issues), dump(issues)).toEqual([]); + }); + + it('accepts `record`, `today()` and both roots in one condition, over every rule in the list', () => { + const issues = validateStackExpressions(stackWith([ + { condition: "record.status == 'open' && value > 1000", style: { backgroundColor: '#fef3c7' } }, + { condition: 'value < record.safety_level', style: { color: '#b45309' } }, + { condition: { dialect: 'cel', source: 'today() > timestamp("2020-01-01T00:00:00Z")' }, style: RED }, + ])); + expect(errorsOf(issues), dump(issues)).toEqual([]); + }); + + it('refuses a condition that does not parse, naming the field and the rule', () => { + const issues = validateStackExpressions(stackWith([ + { condition: 'value < 0', style: RED }, + { condition: 'value < ', style: RED }, + ])); + expect(at(issues, 0)).toEqual([]); + const errors = at(issues, 1); + expect(errors, dump(issues)).toHaveLength(1); + expect(errors[0]!.where).toBe("object 'fx_invoice' · field 'amount' conditionalFormatting[1].condition"); + expect(errors[0]!.source).toBe('value < '); + expect(errors[0]!.message).toMatch(/^invalid CEL predicate: /); + }); + + it('refuses a bare field reference — the row is `record`, as on every record-scoped slot', () => { + const errors = at(validateStackExpressions(stackWith([{ condition: "status == 'paid'", style: RED }])), 0); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/^bare reference `status`/); + }); + + it('refuses a read of a field the object does not declare', () => { + const errors = at(validateStackExpressions(stackWith([{ condition: 'record.balance < 0', style: RED }])), 0); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/`balance`/); + }); + + describe('the scope — `value` and `record`, and no other root', () => { + it('binds exactly `value` and `record`', () => { + expect([...FIELD_FORMATTING_BOUND_ROOTS]).toEqual(['value', 'record']); + // `value` is this surface's own name, never a platform root: the shared + // baseline is not widened for it. + expect((SCOPE_ROOTS as readonly string[]).includes('value')).toBe(false); + }); + + // Every root the judged vocabulary carries other than `record`, each in + // its own reading position — the field-rule family's `previous` and + // `parent` and the user roots included. + const UNBOUND = FIELD_RULE_JUDGED_ROOTS.filter((r) => r !== 'record'); + + it('the unbound set is not vacuous', () => { + expect(UNBOUND).toEqual(expect.arrayContaining(['previous', 'parent', 'current_user', 'app'])); + }); + + it.each(UNBOUND)('refuses `%s` with this surface\'s root verdict, and only that one', (root) => { + const source = `${root}.status == 'paid'`; + const errors = at(validateStackExpressions(stackWith([{ condition: source, style: RED }])), 0); + expect(errors, dump(errors)).toHaveLength(1); + expect(errors[0]!.message).toMatch( + new RegExp(`^\`conditionalFormatting\\[0\\]\\.condition\` reads \`${root}\`, but a field's conditional formatting rule binds only \`value\``), + ); + expect(errors[0]!.source).toBe(source); + }); + + it('a predicate reading two unbound roots earns one verdict, user roots first', () => { + const verdict = fieldFormattingRootIssue('conditionalFormatting[0].condition', "previous.amount < 0 && current_user.id != ''"); + expect(verdict?.root).toBe('current_user'); + }); + + it('no verdict for a condition that does not parse — the syntax pass owns it', () => { + expect(fieldFormattingRootIssue('conditionalFormatting[0].condition', 'previous.amount <')).toBeNull(); + }); + }); + + it('control: a field without the key keeps its verdicts — the field-rule slots are judged as before', () => { + const issues = validateStackExpressions(stackWith(undefined, { visibleWhen: "status == 'open'" })); + expect(errorsOf(issues).filter((x) => x.where.includes('conditionalFormatting'))).toEqual([]); + const visible = errorsOf(issues).filter((x) => x.where === "object 'fx_invoice' · field 'amount' visibleWhen"); + expect(visible).toHaveLength(1); + expect(visible[0]!.message).toMatch(/^bare reference `status`/); + // `value` stays a bare reference on the field-rule family's own slots. + const family = errorsOf(validateStackExpressions(stackWith(undefined, { visibleWhen: 'value > 0' }))) + .filter((x) => x.where === "object 'fx_invoice' · field 'amount' visibleWhen"); + expect(family).toHaveLength(1); + expect(family[0]!.message).toMatch(/`value`/); + }); + + it('a stack with no rules on any field earns no conditional-formatting finding', () => { + const issues = validateStackExpressions(stackWith([])); + expect(issues.filter((x) => x.where.includes('conditionalFormatting'))).toEqual([]); + }); + + describe('the doors', () => { + it('the object save door runs the same pass, at the same position', () => { + const errors = errorsOf(runStackExpressionPasses(stackWith([{ condition: 'value <', style: RED }]), { runtimeWriteType: 'object' })); + expect(errors.map((x) => x.where)).toEqual([whereOf(0)]); + }); + + it('`os build` refuses it under the expression rule id, as an error', () => { + const stack = stackWith([{ condition: 'previous.amount < 0', style: RED }]); + const findings = runAuthoringRules('build', { normalized: stack, parsed: stack }).filter((f) => f.rule === EXPRESSION_INVALID); + expect(findings, dump(findings)).toHaveLength(1); + expect(findings[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error', where: whereOf(0) }); + }); + + it('the runtime object door refuses it under the same rule id, and publishes the valid rule clean', () => { + const refused = runRuntimeAuthoringRules({ type: 'object', item: fxObject([{ condition: 'value <', style: RED }]), context: { objects: [] } }); + const errs = refused.errors.filter((f) => f.rule === EXPRESSION_INVALID); + expect(errs, dump(refused)).toHaveLength(1); + expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error', where: whereOf(0) }); + + const clean = runRuntimeAuthoringRules({ type: 'object', item: fxObject([{ condition: 'value < 0', style: RED }]), context: { objects: [] } }); + expect(clean.errors.filter((f) => f.rule === EXPRESSION_INVALID), dump(clean)).toEqual([]); + }); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index a381c72fb64..bc826435f0a 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3344,6 +3344,15 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s declaredBy: 'SelectOptionSchema', keys: () => Object.keys(SelectOptionSchema.shape), }, + { + // [#22228] A field's cell formatting rule — the list view's own rule + // element, mounted on `FieldSchema.conditionalFormatting`. `condition` is + // the predicate being checked; `style` is never read here. + receiver: 'cellRule', + expected: ['condition'], + declaredBy: 'FieldSchema.conditionalFormatting[]', + keys: () => shapeKeysOf(FieldSchema.shape.conditionalFormatting), + }, ]; /** @@ -3381,7 +3390,9 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // a LITERAL `f.options` read on purpose, for the same reason the two // `*When` slots below are literal: an `(f as AnyRec).options` cast would // have hidden the new surface from this very scan. - expect(read).toEqual(['expression', 'name', 'options', 'readonlyWhen', 'requiredWhen']); + // `conditionalFormatting` joined in #22228 — the cell formatting rules, + // read literally for the same reason. + expect(read).toEqual(['conditionalFormatting', 'expression', 'name', 'options', 'readonlyWhen', 'requiredWhen']); const declared = Object.keys(FieldSchema.shape); const tracked = TRACKED_UNDECLARED_READS.filter((t) => t.receiver === 'f').map((t) => t.key); expect(tracked).toEqual([]); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 95412a7485d..c404d97dbbc 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -264,8 +264,9 @@ function buildAttachedOnReadIndex(objects: AnyRec[]): Map !(SCOPE_ROOTS as readonly string[]).includes(r), +); + +/** + * [#22228] The root verdict for a field's cell formatting rule: a condition + * that reads a root this surface does not bind — anything in + * {@link FIELD_RULE_JUDGED_ROOTS} other than `record` (`value` is not in that + * vocabulary at all). The same membership test as {@link fieldRuleRootIssue}, + * for the same reasons (an allowlist, judged by membership rather than by + * declaredness), over this surface's own allowlist and in its own words: the + * family's message names `previous` and `parent` as bound, and here neither is. + * + * `null` = nothing to report: the source does not parse (the syntax pass owns + * that), or every root it reads is bound here. + */ +export function fieldFormattingRootIssue( + location: string, + source: string, +): { root: string; message: string } | null { + const roots = collectCelRootIdentifiers(source); + if (!roots.ok) return null; + const kept = FIELD_RULE_JUDGED_ROOTS.filter( + (r) => !(FIELD_FORMATTING_BOUND_ROOTS as readonly string[]).includes(r) && roots.roots.includes(r), + ); + if (kept.length === 0) return null; + // One issue per rule, user roots first (the family's tie-break), then the + // judged vocabulary's own order — stable, never AST walk order. + const root = FIELD_RULE_USER_ROOTS.find((r) => kept.includes(r)) ?? kept[0]!; + return { + root, + message: + `\`${location}\` reads \`${root}\`, but a field's conditional formatting rule binds only ` + + `\`value\` (this field's value on the record) and \`record\` (the row the cell belongs to) — ` + + `\`${root}\` is unbound where the cell is styled, so the condition faults and the rule never ` + + `styles the cell. Rewrite the condition against \`value\` or \`record\`.`, + }; +} + /** * [#13935] Does this `@objectstack/formula` error carry the bare-reference * verdict for one of `roots`? @@ -1920,6 +1982,14 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression * alone, which is every site that does not name one. */ servedRowSite?: ServedRowSite, + /** + * [#22228] Roots THIS site binds beyond the platform baseline, handed to + * the shared validator as `ExprSchemaHint.roots` so a read of one is not + * refused as a bare field. Only a field's cell formatting rule passes one + * (`value`, {@link FIELD_FORMATTING_SURFACE_ROOTS}); absent, the call is + * byte for byte what it was. + */ + extraRoots?: readonly string[], ): void => { if (raw == null) return; // Absent unless this is a served-row site on an object that declares a @@ -1929,8 +1999,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // Field types feed the #1928 tier-4 soundness warning; only consulted for // `record`-scoped sites, so it is harmless to pass for flattened ones too. const fieldTypes = objectName ? fieldTypeIndex.get(objectName) : undefined; + // Spread only when given, so every other site's hint is unchanged. + const roots = extraRoots ? { roots: extraRoots } : {}; const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string }, - objectName ? { objectName, fields, attachedOnRead, fieldTypes, scope, traversalHydration } : { scope }); + objectName ? { objectName, fields, attachedOnRead, fieldTypes, scope, traversalHydration, ...roots } : { scope, ...roots }); for (const e of res.errors) { if (fieldRuleVerdictIssued && isBareReferenceToAny(e.message, FIELD_RULE_NOWHERE_BOUND_ROOTS)) continue; issues.push({ where, message: e.message, source: e.source, severity: 'error' }); @@ -2608,6 +2680,34 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression issues.push({ where, message: verdict.message, source: verdict.source, severity: 'error' }); } } + // [#22228] The field's cell formatting rules — `conditionalFormatting`, + // each `{ condition, style }` (the list view's own rule element). Each + // condition is judged by the `record`-scoped `check` with `value` named + // as a root this surface binds (it parses, it reads `record.` + // and never a bare field, the field exists), plus this surface's own + // root verdict: `value` and `record` only. Display only, like + // `visibleWhen`, so none of the write-path gates below (the `parent` + // gate, the null guard, the traversal refusal) applies. Not fenced on an + // object write: the object save door runs this pass too, at this + // position. + // + // `f.conditionalFormatting` is read LITERALLY so #5017's source scan + // checks it against `FieldSchema`, and so is each rule's `condition`. + // The index is the rule's position as authored — the location names it. + const cellRules: readonly unknown[] = Array.isArray(f.conditionalFormatting) ? f.conditionalFormatting : []; + for (const [ri, entry] of [...cellRules].entries()) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const cellRule = entry as AnyRec; + const location = `conditionalFormatting[${ri}].condition`; + const where = `object '${objectName}' · field '${fname}' ${location}`; + const raw: unknown = cellRule.condition; + const ruleSource = celSourceOf(raw); + const verdict = ruleSource ? fieldFormattingRootIssue(location, ruleSource) : null; + check(where, raw, objectName, 'record', verdict !== null, undefined, undefined, FIELD_FORMATTING_SURFACE_ROOTS); + if (verdict) { + issues.push({ where, message: verdict.message, source: ruleSource!, severity: 'error' }); + } + } // [commit e9b526597] Per-OPTION `visibleWhen` — a `select`/`multiselect`/`radio` // option's own predicate (`SelectOptionSchema.visibleWhen`, // `field.zod.ts:143`). It had no traversal here at all, so the whole diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts index 8d8774ea3db..d1c6a57937e 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -292,6 +292,31 @@ describe('[ADR-0106 D1] references to a denied field — inside a READABLE field }); }); + it('drops a cell formatting rule whose condition reads the denied field WHOLE, keeping the others in order (#22228)', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ + conditionalFormatting: [ + { condition: 'value == null', style: { color: '#6b7280' } }, + { condition: { dialect: 'cel', source: 'record.secret_score > 90' }, style: { color: '#b91c1c' } }, + { condition: "record.id != ''", style: { fontWeight: '600' } }, + ], + }), + }); + // Not `{ style }` alone: a rule without its condition would read as + // always-on, so the whole rule goes. + expect(served.fields.title.conditionalFormatting).toEqual([ + { condition: 'value == null', style: { color: '#6b7280' } }, + { condition: "record.id != ''", style: { fontWeight: '600' } }, + ]); + + const allDenied = mask({ + name: 'thing', + fields: fieldsWith({ conditionalFormatting: [{ condition: 'record.secret_score > 90', style: { color: '#b91c1c' } }] }), + }); + expect(allDenied.fields.title).not.toHaveProperty('conditionalFormatting'); + }); + it('scrubs the inline master-detail grid — its columns are THIS (child) object\'s own fields', () => { const served = mask({ name: 'thing', diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index e7828b6c228..743571123c8 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -617,6 +617,13 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { // this object's record. Deleted when it mentions a denied field: the caller // could not evaluate it, so their view keeps the overdue affordance. settledWhen: expression, + // A field's cell formatting rules (#22228): each `{ condition, style }` + // condition is CEL over this object's record (plus the field's own + // `value`). A rule that mentions a denied field is dropped WHOLE — dropping + // only its condition would leave a bare `style` that reads as always-on — + // and an emptied list is deleted. The `style` map names CSS properties, + // never a field. + conditionalFormatting: ruleEntries, relatedListFilter: fieldKeyed, defaultValue: expression, autonumberFormat: expression, diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 76c24f50107..b0d90394cc6 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -309,10 +309,11 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * moved 44 → 45 the same way when it gained `attachedOnRead` (#22386, the * blocks a service attaches per caller on read). `field` moved 75 → 77 the * same way when it gained `dueLike` and `settledWhen` (#22227, a date's - * deadline and when it is settled). + * deadline and when it is settled), and 77 → 78 when it gained + * `conditionalFormatting` (#22228, a field's cell formatting rules). */ const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 21, dataset: 16, field: 77, flow: 23, + agent: 26, app: 30, dashboard: 21, dataset: 16, field: 78, flow: 23, hook: 22, object: 45, page: 25, position: 12, report: 21, skill: 17, tool: 14, }; diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 6f8d9a1d443..b7e718e7bd7 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -275,7 +275,21 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ // aliases are removed in a future major, so both carry a ledger row. 'ui/page.zod.ts:PageComponentSchema.visibleWhen', 'ui/page.zod.ts:PageComponentSchema.visibility', - 'ui/view.zod.ts:ListViewShapeSchema.condition', + // [#22228] The conditional formatting rule's `condition`. Declared inline + // in `ListViewSchema` (`ui/view.zod.ts:ListViewShapeSchema.condition`) + // until it moved to its own module so a field definition could mount the + // same element. ONE declaring position, TWO mounts, and this row speaks + // for the position: the list view's row rule (and the `object-grid` / + // `object-kanban` blocks that reuse that member) is what objectui + // evaluates today. The second mount, `FieldSchema.conditionalFormatting` + // (a field's cell rule, scope `value` + `record`), has NO evaluator yet: + // its reader is objectui's cell renderer, its own card after the spec + // publishes. Its CEL is judged at authoring by lint's field walk + // (`validate-expressions.ts`), a reader of the source, not an evaluator. + // The position is this ledger's unit, so the field mount cannot carry a + // row of its own; the liveness ledger's `field.json` row + // `conditionalFormatting` (`planned`) is where its state is recorded. + 'shared/conditional-formatting.zod.ts:ConditionalFormattingRuleSchema.condition', 'ui/view.zod.ts:FormFieldBaseSchema.visibleWhen', 'ui/view.zod.ts:FormSectionSchema.visibleWhen', 'ui/view.zod.ts:FormFieldBaseSchema.visibleOn', 'ui/view.zod.ts:FormSectionSchema.visibleOn', // `ui/component.zod.ts:onSubmit` (element:form's submit CEL) sat here diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index f725c03bdde..9e137c669c3 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -324,6 +324,7 @@ "data/Field:ackPlaintextMasking", "data/Field:allowCreate", "data/Field:autonumberFormat", + "data/Field:conditionalFormatting", "data/Field:conditionalRequired [RETIRED]", "data/Field:currencyConfig", "data/Field:defaultValue", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index c59681be231..5e48a50aa12 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -115,6 +115,25 @@ "evidence": "packages/spec/src/data/field.zod.ts#DEADLINE_FIELD_TYPES (the parse-time door: refused off `date` / `datetime`, and refused without `dueLike: true`); packages/lint/src/validate-expressions.ts#runStackExpressionPasses (the authoring gate: the field-rule pass judges it as it judges `visibleWhen` — it parses, it reads `record.`, only the family's bound roots — at `os build` / `os validate` and the object save door)", "note": "PLANNED — declared ahead of its one reader by the ruling's own sequencing (#22227, the protocol half of objectui#11815's maintainer ruling D): the host evaluates the predicate per row and hands the date cell one boolean, and that read is objectui#11815, Blocked-by #22227, which lands once the spec publishes. No reader in either repo today — objectui @55e90fd has no `settledWhen` read at all — so until then a settled deadline still paints overdue exactly as it does without the key; nothing is stored or enforced differently. No `authorWarn`: the gap is a display refinement with a named, blocked consumer card, and the authoring half (parse, scope, the `dueLike` pairing) is already enforced. Flip to `live` on the objectui#11815 landing, citing its host helper." }, + "conditionalFormatting": { + "status": "planned", + "verifiedAt": "2026-10-09", + "evidenceScope": "cross-repo", + "evidence": "packages/spec/src/shared/conditional-formatting.zod.ts#ConditionalFormattingRuleSchema (the parse-time door: each rule is exactly `{ condition, style }`, a non-blank condition and a string-to-string style map, the list view's own rule element); packages/lint/src/validate-expressions.ts#fieldFormattingRootIssue (the authoring gate, at `os build` / `os validate` and the object save door: each condition must parse, read `record.` rather than a bare field, name declared fields, and read no root but `value` and `record`)", + "note": "PLANNED — declared ahead of its one reader by the card's own sequencing (#22228, maintainer-directed on objectui#11815: a field-level formatting block for presentational rules that carry no semantic). The reader is objectui's cell renderers, which read the rules through one helper and style the cell; that is its own card, after the spec publishes. No reader in either repo today, measured: objectui @2a48bd4 (main) and the `.objectui-sha` pin f0268ad78 read `conditionalFormatting` only off a list view, the `object-grid` / `object-kanban` blocks and a report (row and card rules; control: plugin-grid ObjectGrid.tsx carries 8 hits at the pin), and no field-level read in packages/fields, plugin-detail, components or react. So until then an authored rule styles nothing; nothing is stored or enforced differently. The authoring half (shape, CEL parse, the `value` / `record` scope) is enforced now. No `authorWarn`, the `settledWhen` precedent: the gap is a display refinement with a named consumer card, and a warned field row would reopen the lint's field walk for one display key. Flip to `live` when objectui's cell helper lands, citing it, and drop the status paragraph that closes the cell-formatting section of content/docs/data-modeling/fields.mdx.", + "children": { + "condition": { + "status": "planned", + "verifiedAt": "2026-10-09", + "note": "The rule's CEL predicate, scope `value` + `record`. Judged at authoring (parse, the scope, declared fields) by the container's evidence; evaluated by nothing until objectui's cell renderers read the rules. Shares the container's verdict and its flip." + }, + "style": { + "status": "planned", + "verifiedAt": "2026-10-09", + "note": "The CSS style map applied to the cell while the condition holds — the list view's own style vocabulary, refused at parse unless string-to-string. Read by nothing until objectui's cell renderers read the rules. Shares the container's verdict and its flip." + } + } + }, "readonly": { "status": "live", "evidence": "packages/objectql/src/validation/rule-validator.ts (stripReadonlyFields — the one strip, UPDATE and INSERT); packages/objectql/src/engine.ts (INSERT call site, isSystem-gated, since the 2026-09-03 ruling)", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 9cc48455a2a..1ea14114cde 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 94 | 0 | 0 | 1 | 1 | 96 | +| `field` | 94 | 0 | 0 | 1 | 3 | 98 | diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 0b13a26fe4a..7e9e524c457 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (202 schemas) +## 3. Schema Inventory by Domain (203 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -91,7 +91,7 @@ here is verifiable from the installed package. | api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 13 | Flow, Approval, BPMN Interop, Control Flow, Webhook, Schedule Organization | -| shared | 15 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | +| shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | | ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | | marketplace | 4 | Marketplace, Package, Package Version, Template Manifest | diff --git a/packages/spec/src/data/field-conditional-formatting.test.ts b/packages/spec/src/data/field-conditional-formatting.test.ts new file mode 100644 index 00000000000..925a60c3c6c --- /dev/null +++ b/packages/spec/src/data/field-conditional-formatting.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22228] `FieldSchema.conditionalFormatting` — a field's cell formatting + * rules, `[{ condition, style }]`, at the parse seam. + * + * The rule element is the list view's own (`ConditionalFormattingRuleSchema`, + * declared once in `shared/conditional-formatting.zod.ts`), so the field adds + * no second style vocabulary. These pins hold that identity, the accepted + * shape, and the refusals the parse gives. The CEL half of the authoring gate + * (it parses, the scope is `value` and `record`) is `@objectstack/lint`'s, + * pinned in `validate-expressions.field-conditional-formatting.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema } from './field.zod'; +import { ListViewSchema } from '../ui/view.zod'; +import { ConditionalFormattingRuleSchema } from '../shared/conditional-formatting.zod'; +import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '../shared/expression.zod'; + +const RED = { color: '#b91c1c' }; +const amount = (conditionalFormatting: unknown) => ({ name: 'amount', label: 'Amount', type: 'currency', conditionalFormatting }); + +/** The first sentence of a message — the part a pin may hold without pinning prose. */ +const firstSentence = (message: string) => message.split(/(?<=\.)\s/)[0]; + +describe('the rule element is the list view\'s own — one declaration, no second copy', () => { + it('FieldSchema and ListViewSchema mount the same element schema', () => { + const fieldElement = FieldSchema.shape.conditionalFormatting.unwrap().element; + const rowElement = ListViewSchema.shape.conditionalFormatting.unwrap().element; + expect(fieldElement).toBe(ConditionalFormattingRuleSchema); + expect(rowElement).toBe(ConditionalFormattingRuleSchema); + }); + + it('a rule list parses to the same value on both members', () => { + const rules = [{ condition: 'value < 0', style: RED }, { condition: { dialect: 'cel', source: 'record.paid == true' }, style: { fontWeight: '600' } }]; + const onField = FieldSchema.parse(amount(rules)).conditionalFormatting; + const onRow = ListViewSchema.parse({ type: 'grid', columns: ['amount'], conditionalFormatting: rules }).conditionalFormatting; + expect(onField).toStrictEqual(onRow); + }); +}); + +describe('FieldSchema accepts conditionalFormatting', () => { + it('accepts a rule with a parsing condition and a style map — the card\'s worked example', () => { + const result = FieldSchema.safeParse(amount([{ condition: 'value < 0', style: RED }])); + expect(result.success).toBe(true); + if (result.success) { + // The bare string normalizes to the evaluated envelope, like every CEL slot. + expect(result.data.conditionalFormatting).toEqual([{ condition: { dialect: 'cel', source: 'value < 0' }, style: RED }]); + } + }); + + it('keeps the rules in authored order', () => { + const rules = [ + { condition: 'value < record.safety_level', style: { color: '#b45309' } }, + { condition: 'value < 0', style: RED }, + ]; + const parsed = FieldSchema.parse({ name: 'stock', label: 'Stock', type: 'number', conditionalFormatting: rules }); + expect(parsed.conditionalFormatting?.map((r) => (r.condition as { source: string }).source)) + .toEqual(['value < record.safety_level', 'value < 0']); + }); + + it.each(['text', 'number', 'select', 'date', 'boolean', 'lookup'])('is not scoped to a type — accepted on `%s`', (type) => { + const extra = type === 'select' ? { options: [{ label: 'High', value: 'high' }] } : type === 'lookup' ? { reference: 'account' } : {}; + const result = FieldSchema.safeParse({ name: 'f', label: 'F', type, ...extra, conditionalFormatting: [{ condition: "value == 'high'", style: RED }] }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('control: a field without the key parses, and the key stays absent', () => { + const result = FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency' }); + expect(result.success).toBe(true); + if (result.success) expect('conditionalFormatting' in result.data).toBe(false); + }); + + it('an empty list is a list — accepted and kept', () => { + expect(FieldSchema.parse(amount([])).conditionalFormatting).toEqual([]); + }); +}); + +describe('FieldSchema refuses a malformed rule, located at the rule', () => { + const REFUSED: Array<[string, unknown, { code: string; path: Array; first?: string }]> = [ + ['one rule not in a list', { condition: 'value < 0', style: RED }, { code: 'invalid_type', path: ['conditionalFormatting'] }], + ['a rule with no `style`', [{ condition: 'value < 0' }], { code: 'invalid_type', path: ['conditionalFormatting', 0, 'style'] }], + ['a non-string style value', [{ condition: 'value < 0', style: { fontWeight: 600 } }], { code: 'invalid_type', path: ['conditionalFormatting', 0, 'style', 'fontWeight'] }], + ['a colour beside `style`', [{ condition: 'value < 0', style: RED, color: 'red' }], { + code: 'unrecognized_keys', + path: ['conditionalFormatting', 0], + first: 'Unrecognized key(s) on this conditional formatting rule: `color`.', + }], + ['the `when` alias for `condition`', [{ when: 'value < 0', style: RED }], { + code: 'unrecognized_keys', + path: ['conditionalFormatting', 0], + first: 'Unrecognized key(s) on this conditional formatting rule: `when`.', + }], + ]; + + it.each(REFUSED)('refuses %s', (_label, rules, expected) => { + const result = FieldSchema.safeParse(amount(rules)); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.code === expected.code && JSON.stringify(i.path) === JSON.stringify(expected.path)); + expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); + if (expected.first) expect(firstSentence(issue!.message)).toBe(expected.first); + }); + + it('the `when` refusal names the canonical key', () => { + const result = FieldSchema.safeParse(amount([{ when: 'value < 0', style: RED }])); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue!.message).toContain('Did you mean `when` → `condition`?'); + }); + + it('refuses a blank condition with the evaluated-slot sentence', () => { + const result = FieldSchema.safeParse(amount([{ condition: ' ', style: RED }])); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => JSON.stringify(i.path) === JSON.stringify(['conditionalFormatting', 0, 'condition'])); + expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); + expect(JSON.stringify(result.error!.issues)).toContain(firstSentence(EVALUATED_EXPRESSION_SOURCE_REQUIRED).slice(0, 60)); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d376e408d29..cd4528f73f3 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -17,6 +17,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { checkFieldCompleteness, FIELD_CHOICE_WITHOUT_OPTIONS } from '../kernel/functional-completeness'; import { SnakeCaseIdentifierSchema, SystemIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import { ConditionalFormattingRuleSchema } from '../shared/conditional-formatting.zod'; import { FilterConditionSchema } from './filter.zod'; import { FIELD_KEY_GUIDANCE } from './authoring-key-lint'; import { DEFAULT_AUTONUMBER_FORMAT } from './autonumber-format'; @@ -1889,6 +1890,32 @@ export const FieldSchema = lazySchema(() => { dueLike: z.boolean().optional().describe("Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording (\"Overdue 3d\") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it."), settledWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it."), + /** + * Cell formatting rules (#22228) — presentational rules with no semantic, + * such as an amount below zero in red or a priority cell coloured by its + * value. The maintainer directed this key on objectui#11815, beside that + * card's ruling D, which keeps a deadline's overdue state a declaration + * (`dueLike` / `settledWhen` above). + * + * The element is the list view's own rule (`ConditionalFormattingRuleSchema`, + * declared once in `shared/conditional-formatting.zod.ts`, which + * `ListViewSchema.conditionalFormatting` mounts too), so there is no second + * style vocabulary. Ordered: the first rule whose `condition` holds applies + * its `style` map to THIS FIELD'S CELL, wherever the field renders. A row + * rule styles the row, and both may apply to one record. + * + * The condition's scope is `value` (this field's value on the record) and + * `record` (the row) — nothing else. `@objectstack/lint`'s + * `validate-expressions.ts` judges it at `os build` / `os validate` and at + * the object save door (ADR-0049): it must parse, read `record.` + * rather than a bare field, name declared fields, and read no root but those + * two. + * + * Display only: nothing on the write path reads it. The consumer is the + * renderer's cells (objectui), which read it after the spec publishes. + */ + conditionalFormatting: z.array(ConditionalFormattingRuleSchema).optional().describe("Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it."), + /** * Form widget override. Names a registered field/UI component to render this * field with, overriding the default widget derived from `type`. Honored by diff --git a/packages/spec/src/shared/conditional-formatting.zod.ts b/packages/spec/src/shared/conditional-formatting.zod.ts new file mode 100644 index 00000000000..b1c34243914 --- /dev/null +++ b/packages/spec/src/shared/conditional-formatting.zod.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module shared/conditional-formatting + * + * The **single declaration** of a conditional formatting rule — + * `{ condition, style }`: a CEL predicate, and the CSS `style` map applied + * while it holds. An ordered list of them is the formatting block; the first + * rule whose `condition` holds wins. + * + * Two members mount the list, and both mount THIS element, so the rule grammar + * and the style vocabulary cannot drift apart: + * + * - `ListViewSchema.conditionalFormatting` — a row rule. It styles the ROW. + * The `object-grid` and `object-kanban` page blocks reuse that member by + * identity (`component.zod.ts`), so they mount this element too. + * - `FieldSchema.conditionalFormatting` — a cell rule (#22228). It styles THAT + * FIELD'S CELL wherever the field renders, and its condition reads `value` + * (the field's value on the record) beside `record` (the row). + * + * Both members can apply to one record: a field rule styles its cell, and a + * row rule styles the row. + * + * ## Why a module of its own + * + * The rule was declared inline in `ListViewSchema` until #22228. A field + * definition cannot import it from there: `ui/view.zod.ts` imports + * `data/field.zod.ts`, so the reverse import would be a cycle, and the data + * layer would depend on the UI layer. This module sits below both. + * + * It is a `.zod.ts` module on purpose. The expression-conformance ledger + * discovers CEL slots by scanning `packages/spec/src/**` `*.zod.ts` files, so + * the `condition` slot stays discoverable here (key + * `shared/conditional-formatting.zod.ts:ConditionalFormattingRuleSchema.condition`). + * A plain `.ts` home would have hidden the slot from that scan. + * + * It is not re-exported from the `shared` barrel. Consumers read the rule + * through the two members above, and their inferred types carry it. + * + * ## What the authoring gate checks for each member + * + * The parse checks the shape: exactly `condition` and `style`, a non-blank + * condition, and a string-to-string style map. The CEL in a FIELD rule's + * condition is also judged at `os build` / `os validate` and at the object + * save door, by `@objectstack/lint`'s `validate-expressions.ts`: the condition + * must parse and may read only `value` and `record` (ADR-0049, declared means + * enforced). + */ + +import { z } from 'zod'; + +import { EvaluatedExpressionInputSchema } from './expression.zod'; +import { strictObject } from './strict-object'; + +/** + * One conditional formatting rule. See the module docblock for the two members + * that mount it. + */ +export const ConditionalFormattingRuleSchema = strictObject({ + surface: 'this conditional formatting rule', + history: + 'Until rule shapes were closed an unknown key on a rule was dropped silently — the rule ' + + 'still applied, without whatever the key was meant to style.', + // `visibleWhen` is the ADR-0089 spelling for a predicate on view/page, and a + // field's own visibility predicate, so an author borrowing it here is using + // a neighbouring surface's correct word — the `visibleWhen → visible` + // category #3746 named, not a typo. + aliases: { when: 'condition', expression: 'condition', visibleWhen: 'condition', rule: 'condition', styles: 'style', css: 'style' }, + guidance: { + // The fix comes first because it holds on both members. The `rowColor` + // pointer holds on a list view only: `rowColor` is a different capability + // of that view (colour the row by one field's value), and pointing a + // colour-only author at it beats making them hand-write a style map. + color: '`color` is not a rule key — put a CSS colour in `style`: `{ condition, style: { color: "#b91c1c" } }`. On a list view, colouring whole rows by one field\'s value has its own block, `rowColor`.', + }, +}, { + condition: EvaluatedExpressionInputSchema.describe('Predicate (CEL) to evaluate.'), + style: z.record(z.string(), z.string()).describe('CSS styles to apply when condition is true'), +}); diff --git a/packages/spec/src/shared/evaluated-slot-population.test.ts b/packages/spec/src/shared/evaluated-slot-population.test.ts index bef92f7058e..eac0aa424cc 100644 --- a/packages/spec/src/shared/evaluated-slot-population.test.ts +++ b/packages/spec/src/shared/evaluated-slot-population.test.ts @@ -232,7 +232,13 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ ['ui/component.zod.ts:RecordAlertProps.visible', () => slot(RecordAlertProps, 'visible')], ['ui/page.zod.ts:PageComponentSchema.visibleWhen', () => slot(PageComponentSchema, 'visibleWhen')], ['ui/page.zod.ts:PageComponentSchema.visibility', () => slot(PageComponentSchema, 'visibility')], - ['ui/view.zod.ts:ListViewShapeSchema.condition', () => slot(element(ListViewSchema, 'conditionalFormatting'), 'condition')], + // [#22228] The conditional formatting rule element moved out of + // `ListViewSchema` into its own module, so the field definition can mount the + // same element (`FieldSchema.conditionalFormatting`). One declaring position, + // two mounts; it is reached through the list view's mount here, and the + // field's mount is the same schema by identity + // (`data/field-conditional-formatting.test.ts`). + ['shared/conditional-formatting.zod.ts:ConditionalFormattingRuleSchema.condition', () => slot(element(ListViewSchema, 'conditionalFormatting'), 'condition')], ['ui/view.zod.ts:FormFieldBaseSchema.visibleWhen', () => slot(FormFieldSchema, 'visibleWhen')], ['ui/view.zod.ts:FormFieldBaseSchema.visibleOn', () => slot(FormFieldSchema, 'visibleOn')], ['ui/view.zod.ts:FormSectionSchema.visibleWhen', () => slot(FormSectionSchema, 'visibleWhen')], diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index fc09e28ce9e..a22537dc26a 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -344,6 +344,17 @@ const LEDGER: ReadonlyArray = [ key: 'print', why: "planned, not yet rendered — the print declaration's keys (paper, margins, running header and footer, page numbers, page-break hints) are validated at parse and its printable block subset is enforced at the authoring doors, but no renderer applies them until the console's print rendering lands, and the ruling places the authoring UI in the Studio page designer with it. A form control offered today would let an author set a layout nothing prints", }, + // #22228 — a field's cell formatting rules land spec-first the same way: + // `planned` in the liveness ledger, read by no renderer until objectui's cell + // card. Offering the key in the field form first would be the UI half of + // declared-not-enforced. + { + kind: 'omit', + type: 'field', + path: ROOT_PATH, + key: 'conditionalFormatting', + why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints", + }, { kind: 'omit', type: 'view', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 26426fc83fd..ff4843a3505 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -54,6 +54,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import { ConditionalFormattingRuleSchema } from '../shared/conditional-formatting.zod'; import { normalizeVisibleWhen } from '../shared/visibility'; import { VISIBILITY_STRICT_OPTIONS } from '../shared/visibility-strict-options'; import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; @@ -2856,24 +2857,15 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ + '`${ctx.selection.ids}` / `${ctx.selection.count}`.', ), - /** Conditional Formatting */ - conditionalFormatting: z.array(strictObject({ - surface: 'this conditional formatting rule', - history: VIEW_HISTORY, - // `visibleWhen` is the ADR-0089 spelling for a predicate on view/page, so - // an author borrowing it here is using a neighbouring surface's correct - // word — the `visibleWhen → visible` category #3746 named, not a typo. - aliases: { when: 'condition', expression: 'condition', visibleWhen: 'condition', rule: 'condition', styles: 'style', css: 'style' }, - // `rowColor` is a real, DIFFERENT capability on the same view; pointing a - // colour-only author at the block that already does it beats making them - // hand-write a style map. - guidance: { - color: 'Row colouring by field value has its own block — see `rowColor` on this list view. To set a CSS colour from a predicate, put it in `style`: `{ condition, style: { color: "#b91c1c" } }`.', - }, - }, { - condition: EvaluatedExpressionInputSchema.describe('Predicate (CEL) to evaluate.'), - style: z.record(z.string(), z.string()).describe('CSS styles to apply when condition is true'), - })).optional().describe('Conditional formatting rules for list rows'), + /** + * Conditional Formatting — the row rule. [#22228] The rule element is + * declared once, in `shared/conditional-formatting.zod.ts`, because a field + * definition mounts the same element for its cell rule + * (`FieldSchema.conditionalFormatting`) and cannot import it from this + * module. The aliases and the `rowColor` guidance this member carried moved + * there with it. + */ + conditionalFormatting: z.array(ConditionalFormattingRuleSchema).optional().describe('Conditional formatting rules for list rows'), /** Inline Edit */ inlineEdit: z.boolean().optional().describe('Allow inline editing of records directly in the list view'), diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index 26a4e30c9b2..0b0123455bc 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -23,6 +23,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, CurrencyValueSchema - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/shared/conditional-formatting.zod.ts` — The **single declaration** of a conditional formatting rule — - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — Exports: SystemIdentifierSchema, SnakeCaseIdentifierSchema, MetadataItemNameSchema - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 9f715ef01da..3584b0b151d 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -41,6 +41,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol +- `node_modules/@objectstack/spec/src/shared/conditional-formatting.zod.ts` — The **single declaration** of a conditional formatting rule — - `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index 36bff69eab3..8e5fa9e07f5 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -39,6 +39,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, - `node_modules/@objectstack/spec/src/security/permission.zod.ts` — Exports: ObjectAccessScopeSchema, ObjectPermissionSchema, EffectiveObjectPermissionSchema, AdminScopeSchema, FieldPermissionSchema - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol +- `node_modules/@objectstack/spec/src/shared/conditional-formatting.zod.ts` — The **single declaration** of a conditional formatting rule — - `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas