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
16 changes: 16 additions & 0 deletions .changeset/25332-setup-option-labels.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -921,11 +921,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
26 changes: 16 additions & 10 deletions packages/platform-objects/src/identity/sys-business-unit.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand All @@ -49,8 +49,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand Down Expand Up @@ -137,12 +137,12 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand All @@ -153,8 +153,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/src/data/driver/config-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { title?: string }> };
expect(json.properties.autoMigrate?.title).toBe('Auto-migrate schema');
});
});
2 changes: 1 addition & 1 deletion packages/spec/src/data/driver/mysql.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/driver/postgres.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/driver/sqlite.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
Loading