diff --git a/.changeset/25332-setup-option-labels.md b/.changeset/25332-setup-option-labels.md new file mode 100644 index 00000000000..eb1f16437b0 --- /dev/null +++ b/.changeset/25332-setup-option-labels.md @@ -0,0 +1,16 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/plugin-sharing': patch +'@objectstack/spec': patch +--- + +fix: Setup forms show option labels instead of machine values on Business Units, Sharing Rules and Datasources + +Clause-②: no +Metadata-protocol: none + +- **`@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. +- **`@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. +- **`@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". + +The English labels flow into the generated translation bundles; the existing zh-CN, ja-JP and es-ES option translations are kept. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 495f45a6171..5e414167b70 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -921,11 +921,11 @@ export const enObjects: NonNullable = { label: "Kind", help: "Categorisation hint — does not change graph semantics.", options: { - company: "company", - division: "division", - department: "department", - office: "office", - cost_center: "cost_center" + company: "Company", + division: "Division", + department: "Department", + office: "Office", + cost_center: "Cost Center" } }, parent_business_unit_id: { diff --git a/packages/platform-objects/src/identity/business-unit-kind-option-labels.test.ts b/packages/platform-objects/src/identity/business-unit-kind-option-labels.test.ts new file mode 100644 index 00000000000..124b1b9ce5a --- /dev/null +++ b/packages/platform-objects/src/identity/business-unit-kind-option-labels.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_business_unit.kind` offers labelled options. Declared as the bare-string + * shorthand, every label equalled its stored value, so the Business Unit form + * showed `cost_center` as option text. The stored values are pinned beside the + * labels: a label is display text, the value is what rows already hold. + */ + +import { describe, it, expect } from 'vitest'; +import { SysBusinessUnit } from './sys-business-unit.object'; + +type Option = { value: string; label: string }; + +describe('sys_business_unit.kind options', () => { + const options = (SysBusinessUnit.fields.kind as { options: Option[] }).options; + + it('keeps the stored values', () => { + expect(options.map((o) => o.value)).toEqual(['company', 'division', 'department', 'office', 'cost_center']); + }); + + it('gives every option a label that is not its machine value', () => { + for (const o of options) { + expect(o.label, o.value).not.toBe(o.value); + } + }); +}); diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index c68f61b6c63..fc041d5a106 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -116,16 +116,22 @@ export const SysBusinessUnit = ObjectSchema.create({ group: 'Identity', }), - kind: Field.select( - ['company', 'division', 'department', 'office', 'cost_center'], - { - label: 'Kind', - required: true, - defaultValue: 'department', - description: 'Categorisation hint — does not change graph semantics.', - group: 'Identity', - }, - ), + // Labelled options: the bare-string shorthand makes each label equal its + // value, so the form showed `cost_center` as option text. + kind: Field.select({ + label: 'Kind', + required: true, + defaultValue: 'department', + description: 'Categorisation hint — does not change graph semantics.', + group: 'Identity', + options: [ + { value: 'company', label: 'Company' }, + { value: 'division', label: 'Division' }, + { value: 'department', label: 'Department' }, + { value: 'office', label: 'Office' }, + { value: 'cost_center', label: 'Cost Center' }, + ], + }), // ── Hierarchy ──────────────────────────────────────────────── // `restrict`, not the lookup default `set_null`: deleting a unit that still diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index ecfd40c1d86..95f933e70c6 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -199,8 +199,14 @@ export const SysRecordShare = ObjectSchema.create({ // gate reads such a row — since guest access flows through the guest // position and share links only (ADR-0090 D11). Held equal to // `RecordShareRecipientType` in `@objectstack/spec/contracts`. - ['user', 'group', 'position', 'unit_and_subordinates'], + // Labelled options, rendered alike on `sys_sharing_rule`. { + options: [ + { value: 'user', label: 'User' }, + { value: 'group', label: 'Group' }, + { value: 'position', label: 'Position' }, + { value: 'unit_and_subordinates', label: 'Business unit and subordinates' }, + ], label: 'Recipient Type', required: true, defaultValue: 'user', @@ -241,8 +247,11 @@ export const SysRecordShare = ObjectSchema.create({ // `edit` while claiming more (ADR-0078 declared-but-unenforced; #3865). // Rows persisted with `full` are normalised to `edit` (grant-time + boot // backfill) and stay honoured by the gates until they are. - ['read', 'edit'], { + options: [ + { value: 'read', label: 'Read' }, + { value: 'edit', label: 'Read and edit' }, + ], label: 'Access Level', required: true, defaultValue: 'read', diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index b9584d07a65..d0b860e90c8 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -176,8 +176,18 @@ export const SysSharingRule = ObjectSchema.create({ // matched record, the other five once per rule. The list is held equal to // the authorable `ShareRecipientType` by `field-recipient.test.ts`, so a // stored row of any authorable kind is never refused at this select. - ['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'field'], + // Each option carries its label: the bare-string shorthand would show + // `unit_and_subordinates` as option text, while the help text below + // already names the options by these labels. { + options: [ + { value: 'user', label: 'User' }, + { value: 'team', label: 'Team' }, + { value: 'business_unit', label: 'Business unit' }, + { value: 'position', label: 'Position' }, + { value: 'unit_and_subordinates', label: 'Business unit and subordinates' }, + { value: 'field', label: 'Field' }, + ], label: 'Recipient Type', required: true, defaultValue: 'business_unit', @@ -226,8 +236,11 @@ export const SysSharingRule = ObjectSchema.create({ // reaches, never WHICH VERBS: delete/transfer come from ownership, the // ADR-0057 DEPTH scopes, and admin scope. Stored `full` rows are // normalised to `edit` on write and by the boot backfill. - ['read', 'edit'], { + options: [ + { value: 'read', label: 'Read' }, + { value: 'edit', label: 'Read and edit' }, + ], label: 'Access Level', required: true, defaultValue: 'read', diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts new file mode 100644 index 00000000000..97d76bfede9 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_sharing_rule.recipient_type` and `access_level` offer labelled options, + * and so does `sys_record_share`, which shares that vocabulary + * (`recipient-vocabulary-consistency.test.ts` holds the two renderings equal). + * Declared as the bare-string shorthand, every label equalled its stored value, + * so the Sharing Rule form showed `unit_and_subordinates` as option text while + * the help text under it already named the options by their labels. The stored + * values are pinned beside the labels: a label is display text, the value is + * what the evaluator matches on. + */ + +import { describe, it, expect } from 'vitest'; +import { SysSharingRule } from './objects/sys-sharing-rule.object.js'; +import { SysRecordShare } from './objects/sys-record-share.object.js'; + +type Option = { value: string; label: string }; +const optionsOf = (field: 'recipient_type' | 'access_level'): Option[] => + (SysSharingRule.fields[field] as { options: Option[] }).options; + +describe('sys_sharing_rule select options', () => { + it('recipient_type keeps its stored values', () => { + expect(optionsOf('recipient_type').map((o) => o.value)).toEqual( + ['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'field'], + ); + }); + + it('access_level keeps its stored values', () => { + expect(optionsOf('access_level').map((o) => o.value)).toEqual(['read', 'edit']); + }); + + it.each(['recipient_type', 'access_level'] as const)('%s labels no option with its machine value', (field) => { + for (const o of optionsOf(field)) { + expect(o.label, o.value).not.toBe(o.value); + } + }); + + it('the recipient_type help text names options the select actually shows', () => { + const help = (SysSharingRule.fields.recipient_type as { description: string }).description; + const labels = optionsOf('recipient_type').map((o) => o.label); + expect(labels).toContain('Business unit and subordinates'); + expect(help).toContain('"Business unit and subordinates"'); + expect(labels).toContain('Field'); + expect(help).toContain('"Field"'); + }); +}); + +describe('sys_record_share select options', () => { + const optionsOf = (field: 'recipient_type' | 'access_level'): Option[] => + (SysRecordShare.fields[field] as { options: Option[] }).options; + + it('keeps the stored values', () => { + expect(optionsOf('recipient_type').map((o) => o.value)).toEqual(['user', 'group', 'position', 'unit_and_subordinates']); + expect(optionsOf('access_level').map((o) => o.value)).toEqual(['read', 'edit']); + }); + + it.each(['recipient_type', 'access_level'] as const)('%s labels no option with its machine value', (field) => { + for (const o of optionsOf(field)) { + expect(o.label, o.value).not.toBe(o.value); + } + }); +}); diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index 1dc5d3be918..33c1b4adbe5 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -35,10 +35,10 @@ export const enObjects: NonNullable = { label: "Recipient Type", help: "Kind of principal that holds the grant", options: { - user: "user", - group: "group", - position: "position", - unit_and_subordinates: "unit_and_subordinates" + user: "User", + group: "Group", + position: "Position", + unit_and_subordinates: "Business unit and subordinates" } }, recipient_id: { @@ -49,8 +49,8 @@ export const enObjects: NonNullable = { label: "Access Level", help: "What the recipient can do — read, or read and edit", options: { - read: "read", - edit: "edit" + read: "Read", + edit: "Read and edit" } }, source: { @@ -137,12 +137,12 @@ export const enObjects: NonNullable = { label: "Recipient Type", 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.", options: { - user: "user", - team: "team", - business_unit: "business_unit", - position: "position", - unit_and_subordinates: "unit_and_subordinates", - field: "field" + user: "User", + team: "Team", + business_unit: "Business unit", + position: "Position", + unit_and_subordinates: "Business unit and subordinates", + field: "Field" } }, recipient_id: { @@ -153,8 +153,8 @@ export const enObjects: NonNullable = { label: "Access Level", help: "What the recipients may do with the matching records — read them, or read and edit them.", options: { - read: "read", - edit: "edit" + read: "Read", + edit: "Read and edit" } }, active: { diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts index 5dd80a3314d..0bebdfdb9d2 100644 --- a/packages/spec/src/data/driver/config-registry.test.ts +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -508,3 +508,15 @@ describe('driver lookups — an OFF-vocabulary id is refused, never answered wit expect(resolveDatabaseDriverId('sqlite3')).toBeUndefined(); }); }); + +/** + * The Studio connection form labels each config row with the JSON-Schema + * `title` and falls back to the raw key, so an untitled `autoMigrate` showed + * the machine key `autoMigrate` as its field label on the New datasource form. + */ +describe('SQL driver `autoMigrate` carries a form title', () => { + it.each(['sqlite', 'postgres', 'mysql'] as const)('%s', (id) => { + const json = getDriverConfigJsonSchemaById(id) as { properties: Record }; + expect(json.properties.autoMigrate?.title).toBe('Auto-migrate schema'); + }); +}); diff --git a/packages/spec/src/data/driver/mysql.zod.ts b/packages/spec/src/data/driver/mysql.zod.ts index 4c4aa0f54a9..762b75e4083 100644 --- a/packages/spec/src/data/driver/mysql.zod.ts +++ b/packages/spec/src/data/driver/mysql.zod.ts @@ -121,7 +121,7 @@ export const MysqlConfigSchema = lazySchema(() => strictObject( ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), /** Dev-only, loosen-only schema self-heal (#2186). */ - autoMigrate: SqlAutoMigrateSchema.optional(), + autoMigrate: SqlAutoMigrateSchema.optional().meta({ title: 'Auto-migrate schema' }), }) .describe('MySQL / MariaDB connection configuration') .superRefine((cfg, ctx) => { diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index 673d368b085..ecba1788f33 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -269,7 +269,7 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( .meta({ title: 'Statement timeout (ms)', externalVocabulary: 'PostgreSQL `statement_timeout`' }), /** Dev-only, loosen-only schema self-heal (#2186). */ - autoMigrate: SqlAutoMigrateSchema.optional(), + autoMigrate: SqlAutoMigrateSchema.optional().meta({ title: 'Auto-migrate schema' }), }) .describe('PostgreSQL connection configuration') .superRefine((cfg, ctx) => { diff --git a/packages/spec/src/data/driver/sqlite.zod.ts b/packages/spec/src/data/driver/sqlite.zod.ts index c13ba705c43..9434178b43c 100644 --- a/packages/spec/src/data/driver/sqlite.zod.ts +++ b/packages/spec/src/data/driver/sqlite.zod.ts @@ -75,7 +75,7 @@ export const SqliteConfigSchema = lazySchema(() => strictObject( .meta({ title: 'Filename' }), /** Dev-only, loosen-only schema self-heal (#2186). */ - autoMigrate: SqlAutoMigrateSchema.optional(), + autoMigrate: SqlAutoMigrateSchema.optional().meta({ title: 'Auto-migrate schema' }), }) .describe('SQLite connection configuration'));