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
34 changes: 34 additions & 0 deletions .changeset/23809-select-option-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/spec': minor
'@objectstack/platform-objects': patch
'@objectstack/metadata-core': patch
---

feat(spec)!: a select option declares its lifecycle `kind` (`open` / `won` / `lost`), and every option of an object's `stageField` must declare it

Clause-②: yes (narrowing)

`SelectOptionSchema` gains one optional key, `kind`, a closed vocabulary of three lifecycle categories that means the same on every object: `open` (a step on the way), `won` (the successful end: a deal's Closed Won, a lead's Converted, a task's Done, a case's Resolved) and `lost` (the unsuccessful end: Closed Lost, Unqualified, Cancelled). It is the spelling `record:path` `stages[].terminal` already takes, so the data layer and the page say one thing one way. Example: `{ label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }`. The exported type `SelectOptionKind` names the three values.

- On a plain single-value `select` / `radio` the key is optional, and an absent kind reads as `open`. Nothing is written in at parse: an option without `kind` parses without it.
- On any other field's options (`multiselect`, `checkboxes`, `tags`, a `multiple: true` select, a lookup's static options) `FieldSchema` refuses it at `options.<i>.kind`: a lifecycle is one value per record.
- A shared `picklist`'s options accept it (the same option shape). A form view's option list does not (`FormSelectOptionSchema`): it is refused there as an unknown key whose message points at the object field's own option.

**BREAKING (authoring):** when an object's `stageField` names a field, every option of that field must declare `kind`. `ObjectSchema` refuses the object otherwise, at every door that parses one (`ObjectSchema.create()`, `defineStack`, `os validate`, `os build`, the metadata save door), with one issue per option at `fields.<field>.options.<i>.kind` naming the field and the option. There is no default under `stageField`. Also refused, at `stageField`: a stage field that takes its options from a `picklist` (the object cannot see them to check), and a stage field carrying options that holds no single option code per record (`multiselect`, a `multiple: true` select). Unchanged: `stageField: false`, a `stageField` naming no declared field, and one naming a field with no options.

Migration: add `kind` to each option of the stage field, marking each terminal state `won` or `lost` and every other state `open`:

- `status: Field.select({ options: [{ label: 'New', value: 'new' }, { label: 'Converted', value: 'converted' }, { label: 'Unqualified', value: 'unqualified' }] })` with `stageField: 'status'` → the same options with `kind: 'open'`, `kind: 'won'` and `kind: 'lost'`.
- A picklist-backed stage field → declare its options inline on the field.
- An object with no linear lifecycle → `stageField: false`.

No conversion makes this edit, because which option ends a record's life successfully is the author's to say. `os migrate meta` lists the D3 entry `stage-field-option-kind-required` with the prescription.

The Studio object designer's options repeater offers `kind`; the field form's options repeater lists it.

The other packages follow the new key:

- **`@objectstack/platform-objects`.** The metadata-form translation bundles (`./metadata-translations`) carry the options repeater's new `kind` row, with its label and help text in zh-CN, ja-JP and es-ES.
- **`@objectstack/metadata-core`.** The published contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) names `salary_grade` as its `stageField`, so its two options now declare `kind: 'open'`, and the fixture meets the stage-field rule: `ObjectSchema` raises no issue at `stageField` or under `fields.salary_grade.options`. The fixture is not otherwise a spec-valid object (its `script` actions carry no `body` or `target`), and this change leaves that as it was.

<!-- adr-0087: registered stage-field-option-kind-required -->
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -963,7 +963,7 @@ Metadata query with filtering, sorting, and pagination
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). |
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. |
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ const result = CurrencyConfigSchema.parse(data);
| **color** | `string` | optional | Color code for badges/charts |
| **default** | `boolean` | optional | Is default option |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. |

### Nested Shape: `Field.referenceDeletedWhen`

Expand Down Expand Up @@ -426,6 +427,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name`
| **color** | `string` | optional | Color code for badges/charts |
| **default** | `boolean` | optional | Is default option |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ const result = ApiMethod.parse(data);
| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. |
| **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). |
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. |
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
Expand Down
3 changes: 3 additions & 0 deletions content/docs/references/data/picklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ A shared option list that select fields reference by name instead of copying opt
| **color** | `string` | optional | Color code for badges/charts |
| **default** | `boolean` | optional | Is default option |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. |


---
Expand All @@ -104,6 +105,7 @@ Options added to a picklist owned by another package (additive only — the `obj
| **color** | `string` | optional | Color code for badges/charts |
| **default** | `boolean` | optional | Is default option |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. |


---
Expand All @@ -129,6 +131,7 @@ The served form of a picklist-bound field: `options` resolved, `picklist` kept
| **color** | `string` | optional | Color code for badges/charts |
| **default** | `boolean` | optional | Is default option |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. |


---
Expand Down
Loading
Loading