Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/22228-field-conditional-formatting.md
Original file line number Diff line number Diff line change
@@ -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.<field>`, 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.
45 changes: 45 additions & 0 deletions content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.<field>`, 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`
Expand Down
8 changes: 8 additions & 0 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }[]` | 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:<widget>`) 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. |
Expand Down Expand Up @@ -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<string, string>` | ✅ | CSS styles to apply when condition is true |

### Nested Shape: `Field.maskingRule`

| Property | Type | Required | Description |
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }[]` | 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:<widget>`) 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. |
Expand Down Expand Up @@ -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<string, string> }[]` | 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:<widget>`) 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. |
Expand Down
Loading
Loading