Skip to content

Commit ebd20ec

Browse files
fix(app-shell): readable option labels for unlabeled datasource enum selects (objectui#13353) (#13499)
🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XWGTsmqrtrt1Zqh1ioWqsT Fixes #13353 ## What changed - `DatasourceResourcePage.tsx` (Setup → Datasources editor): a driver-config JSON Schema `enum` select rendered each raw value (`off`, `safe`) as its option text. The driver-config schema declares no per-option label (triage comment 6359343643: no carrier, no protocol change), so each option's text is now built from its raw value with Studio's existing `humanizeKey` (`metadata-admin/inspectors/json-schema-to-fields.ts`), the same helper `SchemaForm`'s enum selects already use. `off` → "Off", `safe` → "Safe", `read_only` → "Read Only", `read-write` → "Read Write", `fullSync` → "Full Sync". The `SelectItem` value, and therefore what the form saves, stays the raw enum member. No new helper written. - New test `DatasourceResourcePage.enumOptionLabels-13353.test.tsx`: an `autoMigrate` enum (`off | safe`, no labels) shows "Off" / "Safe"; snake, kebab and camel members read as words; choosing "Safe" and "Full Sync" then creating posts `config.autoMigrate: 'safe'` and `config.syncMode: 'fullSync'`. - `DatasourceResourcePage.selectPlaceholderLocale-11252.test.tsx`: its CONTROL case expected the closed trigger to read `remote`; it now reads `Remote`. - `.changeset/13353-datasource-enum-option-labels.md`: patch for `@object-ui/app-shell`. Clause-②: no. Metadata-protocol: none. ## What I ran - Reproduced first: the new test file on unmodified code → 3 failed of 3 (`expected [ 'off', 'safe' ] to deeply equal [ 'Off', 'Safe' ]`, likewise for the mixed-case enum). - `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/datasource/ packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts` → 8 files, 71 tests passed (all seven `DatasourceResourcePage.*.test.tsx` files plus the `humanizeKey` tests). - The other tests naming the page or its module: `spec-symbol-parity.test.ts`, `ResourceHistoryPage.rollbackConfirm-13333.test.tsx`, `scripts/__tests__/spec-symbol-ledger-app-shell-7265.test.ts`, plus `product-neutral-renderer.ratchet.test.ts` → 4 files, 99 tests passed. - `pnpm turbo run type-check --filter=@object-ui/app-shell --continue` → 30 of 30 tasks successful. - `eslint` on the three touched source/test files → 0 errors, 2 warnings, both on lines this PR does not touch (`no-explicit-any` in the `api` helper, `set-state-in-effect` on the load effect). - `node scripts/check-changeset-presence.mjs` → pass; `node scripts/check-changeset-no-major.mjs` → pass; `pnpm check:new-line-citations` → 0 new citations. ## NOT MEASURED - The whole `@object-ui/app-shell` suite and the full `pnpm test`: left to CI's sharded run. Locally only the test files that name the page or the helper were run, so a test elsewhere that asserts a datasource enum option's raw text would not have been seen locally. - No browser check against a live backend; the option text is read from the rendered Radix select in jsdom. - A declared option label (a future `oneOf`/`const`/`title` or similar carrier) is not read; that is a maintainer ruling, not this card. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Co-authored-by: Claude <noreply@anthropic.com>
1 parent bba96ce commit ebd20ec

4 files changed

Lines changed: 165 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): readable option labels for unlabeled datasource enum selects (objectui#13353)
6+
7+
Setup → Datasources: a driver-config JSON Schema `enum` select showed each raw value (`off`, `safe`) as its option text. The driver-config schema declares no per-option label, so each option's text is now built from its raw value with Studio's `humanizeKey`, the helper `SchemaForm`'s enum selects already use (`off` → "Off", `read_only` → "Read Only", `fullSync` → "Full Sync"). The value the form saves stays the raw enum member.
8+
9+
Clause-②: no
10+
Metadata-protocol: none
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#13353 — a driver-config `enum` select shows readable option text.
5+
*
6+
* The create/edit dialog builds its config form from the driver catalog's JSON
7+
* Schema (`GET /api/v1/datasources/drivers`). An `enum` property carries no
8+
* per-option label (no carrier exists in the driver-config schema today), and
9+
* each option rendered `String(opt)`, so the SQL drivers' `autoMigrate` select
10+
* offered the bare words `off` and `safe`. Each option now shows a label built
11+
* from its raw value with Studio's `humanizeKey` (the same helper `SchemaForm`'s
12+
* enum selects use), while the value the form saves stays the raw enum member.
13+
*/
14+
15+
import * as React from 'react';
16+
import { describe, it, expect, vi, afterEach } from 'vitest';
17+
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
18+
import userEvent from '@testing-library/user-event';
19+
import { I18nProvider } from '@object-ui/i18n';
20+
21+
const state = vi.hoisted(() => ({
22+
fetch: (async () => undefined) as unknown as (url: string, init?: RequestInit) => Promise<unknown>,
23+
// STABLE identity, like the real memoized client.
24+
metadataClient: {},
25+
}));
26+
27+
vi.mock('@object-ui/auth', async (importOriginal) => ({
28+
...(await importOriginal<Record<string, unknown>>()),
29+
createAuthenticatedFetch: () => (url: string, init?: RequestInit) => state.fetch(url, init),
30+
}));
31+
vi.mock('../useMetadata.js', () => ({
32+
useMetadataClient: () => state.metadataClient,
33+
}));
34+
vi.mock('sonner', () => ({
35+
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), message: vi.fn() },
36+
}));
37+
38+
import { toast } from 'sonner';
39+
import { DatasourceResourcePage } from './DatasourceResourcePage';
40+
41+
afterEach(() => {
42+
cleanup();
43+
vi.mocked(toast.success).mockClear();
44+
vi.mocked(toast.error).mockClear();
45+
});
46+
47+
/**
48+
* A sqlite-shaped catalog entry: `autoMigrate` is the producer the card names
49+
* (`off | safe`, a title but no option labels), and `syncMode` carries one
50+
* member in each machine spelling the label builder must read.
51+
*/
52+
const SQLITE = {
53+
id: 'sqlite',
54+
label: 'SQLite',
55+
configSchema: {
56+
properties: {
57+
filename: { type: 'string', title: 'Filename' },
58+
autoMigrate: { type: 'string', enum: ['off', 'safe'], title: 'Auto migrate' },
59+
syncMode: { type: 'string', enum: ['read_only', 'read-write', 'fullSync'], title: 'Sync mode' },
60+
},
61+
required: ['filename'],
62+
},
63+
};
64+
65+
/** Serves the catalog and records every create the dialog sends. */
66+
function serve() {
67+
const posts: Array<Record<string, unknown>> = [];
68+
const ok = (data: unknown) => ({
69+
ok: true,
70+
status: 200,
71+
text: async () => JSON.stringify({ success: true, data }),
72+
});
73+
state.fetch = async (url: string, init?: RequestInit) => {
74+
if (url.endsWith('/api/v1/datasources/drivers')) return ok({ drivers: [SQLITE] });
75+
if (url.endsWith('/api/v1/datasources') && init?.method === 'POST') {
76+
posts.push(JSON.parse(String(init.body)));
77+
return ok({ datasource: { name: 'local' } });
78+
}
79+
return ok({ datasources: [] });
80+
};
81+
return posts;
82+
}
83+
84+
/** The select trigger beside a form label (the page's labels carry no `htmlFor`). */
85+
function triggerUnder(label: string): HTMLElement {
86+
const lab = screen.getByText(label, { selector: 'label' });
87+
const trigger = lab.parentElement?.querySelector('[role="combobox"]');
88+
expect(trigger, `no select trigger beside the "${label}" label`).toBeTruthy();
89+
return trigger as HTMLElement;
90+
}
91+
92+
async function openCreateDialog() {
93+
const posts = serve();
94+
render(
95+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
96+
<DatasourceResourcePage />
97+
</I18nProvider>,
98+
);
99+
fireEvent.click(await screen.findByRole('button', { name: /New datasource/ }));
100+
await screen.findByText('Auto migrate', { selector: 'label' });
101+
return posts;
102+
}
103+
104+
/** Opens the select beside `label` and returns its option texts. */
105+
async function optionTexts(label: string): Promise<string[]> {
106+
await userEvent.click(triggerUnder(label));
107+
const options = await screen.findAllByRole('option');
108+
return options.map((o) => o.textContent ?? '');
109+
}
110+
111+
describe('objectui#13353 — an unlabeled driver-config enum shows readable option text', () => {
112+
it('the `autoMigrate` select offers "Off" and "Safe", not the bare values', async () => {
113+
await openCreateDialog();
114+
const texts = await optionTexts('Auto migrate');
115+
expect(texts).toEqual(['Off', 'Safe']);
116+
expect(texts, 'no option reads as a bare lowercase enum value').not.toContain('off');
117+
expect(texts).not.toContain('safe');
118+
});
119+
120+
it('snake, kebab and camel case members each read as words', async () => {
121+
await openCreateDialog();
122+
expect(await optionTexts('Sync mode')).toEqual(['Read Only', 'Read Write', 'Full Sync']);
123+
});
124+
125+
it('the value saved stays the raw enum member', async () => {
126+
const posts = await openCreateDialog();
127+
fireEvent.change(screen.getByText('Name *', { selector: 'label' }).parentElement!.querySelector('input')!, {
128+
target: { value: 'local' },
129+
});
130+
fireEvent.change(screen.getByText('Filename *', { selector: 'label' }).parentElement!.querySelector('input')!, {
131+
target: { value: 'local.db' },
132+
});
133+
await userEvent.click(triggerUnder('Auto migrate'));
134+
await userEvent.click(await screen.findByRole('option', { name: 'Safe' }));
135+
await userEvent.click(triggerUnder('Sync mode'));
136+
await userEvent.click(await screen.findByRole('option', { name: 'Full Sync' }));
137+
expect(triggerUnder('Auto migrate'), 'the closed select shows the chosen label').toHaveTextContent('Safe');
138+
139+
fireEvent.click(screen.getByRole('button', { name: /Create datasource/ }));
140+
await waitFor(() => expect(posts).toHaveLength(1));
141+
expect(posts[0].config).toMatchObject({ filename: 'local.db', autoMigrate: 'safe', syncMode: 'fullSync' });
142+
});
143+
});

‎packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.selectPlaceholderLocale-11252.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ describe('objectui#11252 — an unset driver-config enum reads Studio\'s `engine
110110

111111
it('CONTROL — an enum with a declared default renders its value, not the placeholder', async () => {
112112
const trigger = await openModeSelect('zh-CN', tursoDriver({ default: 'remote' }));
113-
expect(trigger).toHaveTextContent('remote');
113+
// The option text is built from the raw value since objectui#13353.
114+
expect(trigger).toHaveTextContent('Remote');
114115
expect(trigger).not.toHaveTextContent(t('engine.form.selectEllipsis', 'zh-CN'));
115116
});
116117
});

‎packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
import { createAuthenticatedFetch } from '@object-ui/auth';
8383
import { useMetadataClient } from '../useMetadata.js';
8484
import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js';
85+
import { humanizeKey } from '../inspectors/json-schema-to-fields.js';
8586
// objectui#7265 — this page used to re-describe the introspection row as a
8687
// module-local `interface RemoteTable { name; schema?; columnCount? }`. It is
8788
// the SAME wire shape `GET /datasources/:name/remote-tables` returns, and the
@@ -237,8 +238,9 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
237238
const metaClient = useMetadataClient();
238239
// objectui#13460 — the page's copy is `engine.datasource.*` in the Studio
239240
// table (en-US + zh-CN, the `engine.*` carve-out in `packages/i18n/README.md`).
240-
// Author data (names, driver labels and descriptions, server messages) and the
241-
// driver config schema's enum option values (objectui#13353) stay as served.
241+
// Author data (names, driver labels and descriptions, server messages) stays
242+
// as served; an enum option's text is built from its value (objectui#13353,
243+
// `renderField`).
242244
const locale = useMetadataLocale();
243245
/** The locale the memoised loaders below read at call time, so a language switch does not refetch. */
244246
const localeRef = React.useRef(locale);
@@ -586,13 +588,18 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
586588
// An unset enum (Turso's optional `mode`, say) shows Studio's own
587589
// `engine.form.selectEllipsis`, the word `SchemaForm`'s enum selects
588590
// show, in the designer's locale (objectui#11252).
591+
// objectui#13353 — the driver-config schema declares no per-option label,
592+
// so each option's text is built from its raw value with `humanizeKey`,
593+
// the helper `SchemaForm`'s enum selects use (`off` → "Off",
594+
// `read_only` → "Read Only", `fullSync` → "Full Sync"). The value the
595+
// form saves stays the raw enum member.
589596
return (
590597
<div key={key} className="space-y-1">
591598
<Label className="text-xs">{label}{required ? ' *' : ''}</Label>
592599
<Select value={value != null ? String(value) : ''} onValueChange={(v) => setConfigValue(key, v)}>
593600
<SelectTrigger><SelectValue placeholder={t('engine.form.selectEllipsis', locale)} /></SelectTrigger>
594601
<SelectContent>
595-
{prop.enum.map((opt) => <SelectItem key={String(opt)} value={String(opt)}>{String(opt)}</SelectItem>)}
602+
{prop.enum.map((opt) => <SelectItem key={String(opt)} value={String(opt)}>{humanizeKey(String(opt))}</SelectItem>)}
596603
</SelectContent>
597604
</Select>
598605
</div>

0 commit comments

Comments
 (0)