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
13 changes: 13 additions & 0 deletions .changeset/17506-select-option-row-titles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": patch
---

`SelectOptionSchema`'s six row properties carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of a field's `options` table (#17506).

Clause-②: no

Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. `SelectOptionSchema` carried no `title` on any row property, so the fallback arm ran and the maker saw `label` / `value` / `description` / `color` / `default` / `visibleWhen` inside an otherwise translated panel — **in every locale, English included**. Titles are hard-coded English by design: `system/translation.zod.ts` states that a row property renders from `items.properties[k].title`, and `resolveMetadataFormSchemaTitles` only ever REPLACES a title that is already there, so an untitled property has no layer for a translation to overlay.

- **One edit clears two carriers.** `field:options` and `object:fields.options` resolve to the *same* `SelectOptionSchema` object — `FieldSchema.options` is `z.array(SelectOptionSchema)` and `object.fields` is a `z.record(..., FieldSchema)` of that same `FieldSchema` — verified by object identity (`===`) against the schemas `getMetadataTypeSchema('field')` and `getMetadataTypeSchema('object')` actually return, with `FormSelectOptionSchema` as the firing control that the probe can tell two schemas apart. Both entries are deleted from the shrink-only `repeater-item-titles` ledger in the same change; `object.zod.ts` needed no edit.
- **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical, and the pinned accept/refuse suites for this shape (`editability-boundary`, `visible-when-alias-guidance`, `form-select-option`, `evaluated-slot-population`) pass unchanged.
- **The form-view face inherits the titles for free.** `FormSelectOptionSchema` is a shape-level Omit that reuses the same property schema instances, so the five keys it keeps arrive titled too, and its `default`-refusal is untouched.
12 changes: 6 additions & 6 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,8 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
// no existing pointer is shadowed (`alias-integrity.test.ts`, #7889).
guidanceSets: [SELECT_OPTION_EDITABILITY_GUIDANCE],
}, {
label: z.string().describe('Display label (human-readable, any case allowed)'),
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'),
label: z.string().describe('Display label (human-readable, any case allowed)').meta({ title: 'Label' }),
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)').meta({ title: 'Value' }),
/**
* Optional secondary text for the option (objectui#6153, inheriting the
* objectui#6140 ruling frame — maintainer 2026-08-25: a key that is
Expand All @@ -356,9 +356,9 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
* spelling on its own metadata type (maintainer ruling 2026-09-02 on
* objectui#6153).
*/
description: z.string().optional().describe('Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text.'),
color: z.string().optional().describe('Color code for badges/charts'),
default: z.boolean().optional().describe('Is default option'),
description: z.string().optional().describe('Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text.').meta({ title: 'Description' }),
color: z.string().optional().describe('Color code for badges/charts').meta({ title: 'Color' }),
default: z.boolean().optional().describe('Is default option').meta({ title: 'Default' }),
/**
* Per-option visibility predicate (CEL) — the option is offered only when this
* evaluates TRUE. Omit = always available. Evaluated against the live `record`
Expand All @@ -384,7 +384,7 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
* rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the
* dropdown alone is bypassable.
*/
visibleWhen: EvaluatedExpressionInputSchema.optional().describe("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 — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"),
visibleWhen: EvaluatedExpressionInputSchema.optional().describe("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 — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`").meta({ title: 'Visible When' }),
}));

/**
Expand Down
20 changes: 10 additions & 10 deletions packages/spec/src/kernel/repeater-item-titles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,19 @@ const FORMS: ReadonlyArray<readonly [string, unknown]> = [
* Carriers still owed titles, as measured on `origin/main` at
* e758131b3900eb13260f03643e295ca6d625c42b. SHRINK-ONLY — see the header.
*
* The remaining `view.*` / `field.*` entries were fenced out of #17232's
* round by in-flight PRs on their carrier files (#17360 `view.zod.ts`,
* #17477 `field.zod.ts` — `field.options` and `object.fields.options` are
* the same `SelectOptionSchema`). This pin OBSERVES them without editing
* them, which is why the set below is the rest of the class and not the
* slice one PR could reach.
* The remaining `view.*` entries were fenced out of #17232's round by an
* in-flight PR on their carrier file (#17360 `view.zod.ts`). This pin
* OBSERVES them without editing them, which is why the set below is the rest
* of the class and not the slice one PR could reach.
*
* `dashboard:widgets` and `dashboard:globalFilters` were paid by #17505 and
* DELETED from this set — a paid debt leaves no entry behind.
* `dashboard:widgets` and `dashboard:globalFilters` were paid by #17505,
* `field:options` and `object:fields.options` by #17506 — one edit for both,
* because the two carriers resolve to the SAME `SelectOptionSchema` object
* (`FieldSchema.options` is `z.array(SelectOptionSchema)` and `object.fields`
* is a record of that same `FieldSchema`). All four are DELETED from this set
* — a paid debt leaves no entry behind.
*/
const LEDGER: ReadonlySet<string> = new Set([
'field:options',
'object:fields.options',
'view:columns',
'view:sort',
'view:tabs',
Expand Down
Loading