Skip to content

Commit bdf762c

Browse files
fix(platform-objects, plugin-sharing, spec): Setup forms show option labels instead of machine values (#25370)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao9syRPnMJSuZZ5jYxme4Xun)_ Fixes #25332 Clause-②: no Metadata-protocol: none ## What changed Setup forms showed machine values where an administrator expects labels. This PR fixes the declarations that produce them. The stored values do not change. | Form | Field | Producer | Fix | |---|---|---|---| | Business Units | Kind (`cost_center` …) | `packages/platform-objects/src/identity/sys-business-unit.object.ts` | bare-string options become `{ value, label }` | | Sharing Rules | Recipient Type (`business_unit`, `unit_and_subordinates` …), Access Level (`read`, `edit`) | `packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts` | same | | (same vocabulary) | Recipient Type, Access Level on `sys_record_share` | `packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts` | same, see below | | Datasources | field label `autoMigrate` | `packages/spec/src/data/driver/{sqlite,postgres,mysql}.zod.ts` | the key gains `.meta({ title: 'Auto-migrate schema' })` | - `Field.select([...strings])` makes each label equal to its string, so a snake_case value is shown as-is. The options now carry English labels. The Recipient Type labels are the ones its help text already used ("Business unit and subordinates", "Field"). - The en translation bundles are regenerated (`node scripts/check-i18n-bundles.mjs --write`). The existing zh-CN, ja-JP and es-ES option translations were already present and are kept unchanged. - **Where the producer sits, and why:** the dispatch expected the `autoMigrate` label on a datasource object. Measured, it is the driver-config JSON Schema in `packages/spec`: the console's datasource form labels each config row with the property's `title` and falls back to the raw key, and `autoMigrate` was the one SQL-driver key without a title. So the fix is at the spec producer, never a renderer fallback. - **In-place fix of `sys_record_share`:** `recipient-vocabulary-consistency.test.ts` holds one rendering per option key across both sharing objects, so labelling the rule's options alone turns it red. The record share gets the same labels. This is the same defect class with a mechanical fix and the same gate family; the claim's file surface did not name this file, and the seat is told. ## Pins (one per declaration) - `packages/platform-objects/src/identity/business-unit-kind-option-labels.test.ts`: values unchanged, no label equals its value. - `packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts`: the same for both fields on both sharing objects, plus the help text naming labels the select shows. - `packages/spec/src/data/driver/config-registry.test.ts`: the `autoMigrate` JSON-Schema title on sqlite, postgres and mysql. ## Verification (head `ce8bac22`) - `pnpm --filter @objectstack/spec exec vitest run src/data/driver/config-registry.test.ts`: 39/39. - `pnpm --filter @objectstack/platform-objects test`: 89 files, 1320 tests passed. - `pnpm --filter @objectstack/plugin-sharing test`: 54 files, 1209 tests passed. The first run, before `sys_record_share` was labelled, failed `recipient-vocabulary-consistency.test.ts` as described above. - `typecheck` for spec, platform-objects, plugin-sharing: exit 0. - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date. - `dispatch-gates --commands`: 91 derived, 89 exit 0, 2 NOT MEASURED (exit 3, prerequisites): `check-plugin-teardown-shape.mjs --self-test` needs an unshallowed clone; `check:dual-build-cjs-loads` needs every package's `dist/`. CI runs both. ## Acceptance notes - **Not in this PR, objectui:** "Display name\* `name`" is objectui's `packages/app-shell/src/views/metadata-admin/SchemaForm.tsx`. It renders the field key in a `code` chip beside the label whenever the label is not the prettified key. The Packages form takes its labels from objectui's `package-schema.ts`, so no declaration here produces it. The seat files the objectui card. - **Not in this PR, objectui:** the datasource form (objectui `datasource/DatasourceResourcePage.tsx`) renders enum options by raw value, so `autoMigrate`'s options still read `off` / `safe`. JSON Schema has no option-label channel for it to read. - The datasource config titles are English only: the form reads them straight from the JSON Schema, and no translation path covers driver-config titles. - Other driver-config keys with no title, which render their raw key wherever the form shows them: memory `initialData` / `strictMode` / `persistence`, mongodb `options`, turso `encryptionKey` / `sync` / `timeout`. They are not on the four forms the card names. --- _Generated by [Claude Code](https://claude.ai/code/session_01QUKVainSF9Fx4YDaSPyE64)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUKVainSF9Fx4YDaSPyE64 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be318db commit bdf762c

12 files changed

Lines changed: 182 additions & 36 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/plugin-sharing': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix: Setup forms show option labels instead of machine values on Business Units, Sharing Rules and Datasources
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **`@objectstack/platform-objects`:** `sys_business_unit.kind` declared its options as bare strings, so each label equalled its stored value and the form offered `cost_center`. The options now carry labels (Company, Division, Department, Office, Cost Center). The stored values are unchanged.
13+
- **`@objectstack/plugin-sharing`:** `sys_sharing_rule.recipient_type` and `access_level` declared bare-string options too. They now carry labels (User, Team, Business unit, Position, Business unit and subordinates, Field; Read, Read and edit), which are the names the Recipient Type help text already used. `sys_record_share` declares the same recipient and access vocabulary, so its `recipient_type` (User, Group, Position, Business unit and subordinates) and `access_level` options carry the same labels. The stored values are unchanged.
14+
- **`@objectstack/spec`:** the `autoMigrate` key of the SQLite, PostgreSQL and MySQL driver configs had no JSON-Schema `title`, so the New datasource form labelled the field with the raw key `autoMigrate`. It is now titled "Auto-migrate schema".
15+
16+
The English labels flow into the generated translation bundles; the existing zh-CN, ja-JP and es-ES option translations are kept.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -921,11 +921,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
921921
label: "Kind",
922922
help: "Categorisation hint — does not change graph semantics.",
923923
options: {
924-
company: "company",
925-
division: "division",
926-
department: "department",
927-
office: "office",
928-
cost_center: "cost_center"
924+
company: "Company",
925+
division: "Division",
926+
department: "Department",
927+
office: "Office",
928+
cost_center: "Cost Center"
929929
}
930930
},
931931
parent_business_unit_id: {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_business_unit.kind` offers labelled options. Declared as the bare-string
5+
* shorthand, every label equalled its stored value, so the Business Unit form
6+
* showed `cost_center` as option text. The stored values are pinned beside the
7+
* labels: a label is display text, the value is what rows already hold.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { SysBusinessUnit } from './sys-business-unit.object';
12+
13+
type Option = { value: string; label: string };
14+
15+
describe('sys_business_unit.kind options', () => {
16+
const options = (SysBusinessUnit.fields.kind as { options: Option[] }).options;
17+
18+
it('keeps the stored values', () => {
19+
expect(options.map((o) => o.value)).toEqual(['company', 'division', 'department', 'office', 'cost_center']);
20+
});
21+
22+
it('gives every option a label that is not its machine value', () => {
23+
for (const o of options) {
24+
expect(o.label, o.value).not.toBe(o.value);
25+
}
26+
});
27+
});

‎packages/platform-objects/src/identity/sys-business-unit.object.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,16 +116,22 @@ export const SysBusinessUnit = ObjectSchema.create({
116116
group: 'Identity',
117117
}),
118118

119-
kind: Field.select(
120-
['company', 'division', 'department', 'office', 'cost_center'],
121-
{
122-
label: 'Kind',
123-
required: true,
124-
defaultValue: 'department',
125-
description: 'Categorisation hint — does not change graph semantics.',
126-
group: 'Identity',
127-
},
128-
),
119+
// Labelled options: the bare-string shorthand makes each label equal its
120+
// value, so the form showed `cost_center` as option text.
121+
kind: Field.select({
122+
label: 'Kind',
123+
required: true,
124+
defaultValue: 'department',
125+
description: 'Categorisation hint — does not change graph semantics.',
126+
group: 'Identity',
127+
options: [
128+
{ value: 'company', label: 'Company' },
129+
{ value: 'division', label: 'Division' },
130+
{ value: 'department', label: 'Department' },
131+
{ value: 'office', label: 'Office' },
132+
{ value: 'cost_center', label: 'Cost Center' },
133+
],
134+
}),
129135

130136
// ── Hierarchy ────────────────────────────────────────────────
131137
// `restrict`, not the lookup default `set_null`: deleting a unit that still

‎packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,14 @@ export const SysRecordShare = ObjectSchema.create({
199199
// gate reads such a row — since guest access flows through the guest
200200
// position and share links only (ADR-0090 D11). Held equal to
201201
// `RecordShareRecipientType` in `@objectstack/spec/contracts`.
202-
['user', 'group', 'position', 'unit_and_subordinates'],
202+
// Labelled options, rendered alike on `sys_sharing_rule`.
203203
{
204+
options: [
205+
{ value: 'user', label: 'User' },
206+
{ value: 'group', label: 'Group' },
207+
{ value: 'position', label: 'Position' },
208+
{ value: 'unit_and_subordinates', label: 'Business unit and subordinates' },
209+
],
204210
label: 'Recipient Type',
205211
required: true,
206212
defaultValue: 'user',
@@ -241,8 +247,11 @@ export const SysRecordShare = ObjectSchema.create({
241247
// `edit` while claiming more (ADR-0078 declared-but-unenforced; #3865).
242248
// Rows persisted with `full` are normalised to `edit` (grant-time + boot
243249
// backfill) and stay honoured by the gates until they are.
244-
['read', 'edit'],
245250
{
251+
options: [
252+
{ value: 'read', label: 'Read' },
253+
{ value: 'edit', label: 'Read and edit' },
254+
],
246255
label: 'Access Level',
247256
required: true,
248257
defaultValue: 'read',

‎packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,18 @@ export const SysSharingRule = ObjectSchema.create({
176176
// matched record, the other five once per rule. The list is held equal to
177177
// the authorable `ShareRecipientType` by `field-recipient.test.ts`, so a
178178
// stored row of any authorable kind is never refused at this select.
179-
['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'field'],
179+
// Each option carries its label: the bare-string shorthand would show
180+
// `unit_and_subordinates` as option text, while the help text below
181+
// already names the options by these labels.
180182
{
183+
options: [
184+
{ value: 'user', label: 'User' },
185+
{ value: 'team', label: 'Team' },
186+
{ value: 'business_unit', label: 'Business unit' },
187+
{ value: 'position', label: 'Position' },
188+
{ value: 'unit_and_subordinates', label: 'Business unit and subordinates' },
189+
{ value: 'field', label: 'Field' },
190+
],
181191
label: 'Recipient Type',
182192
required: true,
183193
defaultValue: 'business_unit',
@@ -226,8 +236,11 @@ export const SysSharingRule = ObjectSchema.create({
226236
// reaches, never WHICH VERBS: delete/transfer come from ownership, the
227237
// ADR-0057 DEPTH scopes, and admin scope. Stored `full` rows are
228238
// normalised to `edit` on write and by the boot backfill.
229-
['read', 'edit'],
230239
{
240+
options: [
241+
{ value: 'read', label: 'Read' },
242+
{ value: 'edit', label: 'Read and edit' },
243+
],
231244
label: 'Access Level',
232245
required: true,
233246
defaultValue: 'read',
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_sharing_rule.recipient_type` and `access_level` offer labelled options,
5+
* and so does `sys_record_share`, which shares that vocabulary
6+
* (`recipient-vocabulary-consistency.test.ts` holds the two renderings equal).
7+
* Declared as the bare-string shorthand, every label equalled its stored value,
8+
* so the Sharing Rule form showed `unit_and_subordinates` as option text while
9+
* the help text under it already named the options by their labels. The stored
10+
* values are pinned beside the labels: a label is display text, the value is
11+
* what the evaluator matches on.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { SysSharingRule } from './objects/sys-sharing-rule.object.js';
16+
import { SysRecordShare } from './objects/sys-record-share.object.js';
17+
18+
type Option = { value: string; label: string };
19+
const optionsOf = (field: 'recipient_type' | 'access_level'): Option[] =>
20+
(SysSharingRule.fields[field] as { options: Option[] }).options;
21+
22+
describe('sys_sharing_rule select options', () => {
23+
it('recipient_type keeps its stored values', () => {
24+
expect(optionsOf('recipient_type').map((o) => o.value)).toEqual(
25+
['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'field'],
26+
);
27+
});
28+
29+
it('access_level keeps its stored values', () => {
30+
expect(optionsOf('access_level').map((o) => o.value)).toEqual(['read', 'edit']);
31+
});
32+
33+
it.each(['recipient_type', 'access_level'] as const)('%s labels no option with its machine value', (field) => {
34+
for (const o of optionsOf(field)) {
35+
expect(o.label, o.value).not.toBe(o.value);
36+
}
37+
});
38+
39+
it('the recipient_type help text names options the select actually shows', () => {
40+
const help = (SysSharingRule.fields.recipient_type as { description: string }).description;
41+
const labels = optionsOf('recipient_type').map((o) => o.label);
42+
expect(labels).toContain('Business unit and subordinates');
43+
expect(help).toContain('"Business unit and subordinates"');
44+
expect(labels).toContain('Field');
45+
expect(help).toContain('"Field"');
46+
});
47+
});
48+
49+
describe('sys_record_share select options', () => {
50+
const optionsOf = (field: 'recipient_type' | 'access_level'): Option[] =>
51+
(SysRecordShare.fields[field] as { options: Option[] }).options;
52+
53+
it('keeps the stored values', () => {
54+
expect(optionsOf('recipient_type').map((o) => o.value)).toEqual(['user', 'group', 'position', 'unit_and_subordinates']);
55+
expect(optionsOf('access_level').map((o) => o.value)).toEqual(['read', 'edit']);
56+
});
57+
58+
it.each(['recipient_type', 'access_level'] as const)('%s labels no option with its machine value', (field) => {
59+
for (const o of optionsOf(field)) {
60+
expect(o.label, o.value).not.toBe(o.value);
61+
}
62+
});
63+
});

‎packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
3535
label: "Recipient Type",
3636
help: "Kind of principal that holds the grant",
3737
options: {
38-
user: "user",
39-
group: "group",
40-
position: "position",
41-
unit_and_subordinates: "unit_and_subordinates"
38+
user: "User",
39+
group: "Group",
40+
position: "Position",
41+
unit_and_subordinates: "Business unit and subordinates"
4242
}
4343
},
4444
recipient_id: {
@@ -49,8 +49,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
4949
label: "Access Level",
5050
help: "What the recipient can do — read, or read and edit",
5151
options: {
52-
read: "read",
53-
edit: "edit"
52+
read: "Read",
53+
edit: "Read and edit"
5454
}
5555
},
5656
source: {
@@ -137,12 +137,12 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
137137
label: "Recipient Type",
138138
help: "Who receives access. Picking a team, business unit or position gives access to everyone in it. \"Business unit and subordinates\" also covers every unit below the one you pick. \"Field\" shares each record with the user or users held in one of its own fields — name that field as the recipient.",
139139
options: {
140-
user: "user",
141-
team: "team",
142-
business_unit: "business_unit",
143-
position: "position",
144-
unit_and_subordinates: "unit_and_subordinates",
145-
field: "field"
140+
user: "User",
141+
team: "Team",
142+
business_unit: "Business unit",
143+
position: "Position",
144+
unit_and_subordinates: "Business unit and subordinates",
145+
field: "Field"
146146
}
147147
},
148148
recipient_id: {
@@ -153,8 +153,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
153153
label: "Access Level",
154154
help: "What the recipients may do with the matching records — read them, or read and edit them.",
155155
options: {
156-
read: "read",
157-
edit: "edit"
156+
read: "Read",
157+
edit: "Read and edit"
158158
}
159159
},
160160
active: {

‎packages/spec/src/data/driver/config-registry.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,3 +508,15 @@ describe('driver lookups — an OFF-vocabulary id is refused, never answered wit
508508
expect(resolveDatabaseDriverId('sqlite3')).toBeUndefined();
509509
});
510510
});
511+
512+
/**
513+
* The Studio connection form labels each config row with the JSON-Schema
514+
* `title` and falls back to the raw key, so an untitled `autoMigrate` showed
515+
* the machine key `autoMigrate` as its field label on the New datasource form.
516+
*/
517+
describe('SQL driver `autoMigrate` carries a form title', () => {
518+
it.each(['sqlite', 'postgres', 'mysql'] as const)('%s', (id) => {
519+
const json = getDriverConfigJsonSchemaById(id) as { properties: Record<string, { title?: string }> };
520+
expect(json.properties.autoMigrate?.title).toBe('Auto-migrate schema');
521+
});
522+
});

‎packages/spec/src/data/driver/mysql.zod.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export const MysqlConfigSchema = lazySchema(() => strictObject(
121121
ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }),
122122

123123
/** Dev-only, loosen-only schema self-heal (#2186). */
124-
autoMigrate: SqlAutoMigrateSchema.optional(),
124+
autoMigrate: SqlAutoMigrateSchema.optional().meta({ title: 'Auto-migrate schema' }),
125125
})
126126
.describe('MySQL / MariaDB connection configuration')
127127
.superRefine((cfg, ctx) => {

0 commit comments

Comments
 (0)