From a7f7aca22e2247e8a7f74dbadf7484cd37cffb49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:12:11 +0000 Subject: [PATCH] fix(app-shell): readable option labels for unlabeled datasource enum selects (objectui#13353) The datasource editor's driver-config form rendered each JSON Schema enum member as its own option text, so the SQL drivers' autoMigrate select offered the bare words "off" and "safe". The driver-config schema declares no per-option label, so each option's text is now built from the raw value with Studio's humanizeKey, the helper SchemaForm's enum selects already use. The value the form saves stays the raw enum member. Adds DatasourceResourcePage.enumOptionLabels-13353.test.tsx (red on the unmodified page) and moves the #11252 control's expected trigger text from "remote" to "Remote". Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XWGTsmqrtrt1Zqh1ioWqsT --- .../13353-datasource-enum-option-labels.md | 10 ++ ...sourcePage.enumOptionLabels-13353.test.tsx | 143 ++++++++++++++++++ ...age.selectPlaceholderLocale-11252.test.tsx | 3 +- .../datasource/DatasourceResourcePage.tsx | 13 +- 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 .changeset/13353-datasource-enum-option-labels.md create mode 100644 packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.enumOptionLabels-13353.test.tsx diff --git a/.changeset/13353-datasource-enum-option-labels.md b/.changeset/13353-datasource-enum-option-labels.md new file mode 100644 index 0000000000..7dd0584757 --- /dev/null +++ b/.changeset/13353-datasource-enum-option-labels.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): readable option labels for unlabeled datasource enum selects (objectui#13353) + +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. + +Clause-②: no +Metadata-protocol: none diff --git a/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.enumOptionLabels-13353.test.tsx b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.enumOptionLabels-13353.test.tsx new file mode 100644 index 0000000000..84f4cdb1ff --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.enumOptionLabels-13353.test.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#13353 — a driver-config `enum` select shows readable option text. + * + * The create/edit dialog builds its config form from the driver catalog's JSON + * Schema (`GET /api/v1/datasources/drivers`). An `enum` property carries no + * per-option label (no carrier exists in the driver-config schema today), and + * each option rendered `String(opt)`, so the SQL drivers' `autoMigrate` select + * offered the bare words `off` and `safe`. Each option now shows a label built + * from its raw value with Studio's `humanizeKey` (the same helper `SchemaForm`'s + * enum selects use), while the value the form saves stays the raw enum member. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@object-ui/i18n'; + +const state = vi.hoisted(() => ({ + fetch: (async () => undefined) as unknown as (url: string, init?: RequestInit) => Promise, + // STABLE identity, like the real memoized client. + metadataClient: {}, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + createAuthenticatedFetch: () => (url: string, init?: RequestInit) => state.fetch(url, init), +})); +vi.mock('../useMetadata.js', () => ({ + useMetadataClient: () => state.metadataClient, +})); +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), message: vi.fn() }, +})); + +import { toast } from 'sonner'; +import { DatasourceResourcePage } from './DatasourceResourcePage'; + +afterEach(() => { + cleanup(); + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); +}); + +/** + * A sqlite-shaped catalog entry: `autoMigrate` is the producer the card names + * (`off | safe`, a title but no option labels), and `syncMode` carries one + * member in each machine spelling the label builder must read. + */ +const SQLITE = { + id: 'sqlite', + label: 'SQLite', + configSchema: { + properties: { + filename: { type: 'string', title: 'Filename' }, + autoMigrate: { type: 'string', enum: ['off', 'safe'], title: 'Auto migrate' }, + syncMode: { type: 'string', enum: ['read_only', 'read-write', 'fullSync'], title: 'Sync mode' }, + }, + required: ['filename'], + }, +}; + +/** Serves the catalog and records every create the dialog sends. */ +function serve() { + const posts: Array> = []; + const ok = (data: unknown) => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ success: true, data }), + }); + state.fetch = async (url: string, init?: RequestInit) => { + if (url.endsWith('/api/v1/datasources/drivers')) return ok({ drivers: [SQLITE] }); + if (url.endsWith('/api/v1/datasources') && init?.method === 'POST') { + posts.push(JSON.parse(String(init.body))); + return ok({ datasource: { name: 'local' } }); + } + return ok({ datasources: [] }); + }; + return posts; +} + +/** The select trigger beside a form label (the page's labels carry no `htmlFor`). */ +function triggerUnder(label: string): HTMLElement { + const lab = screen.getByText(label, { selector: 'label' }); + const trigger = lab.parentElement?.querySelector('[role="combobox"]'); + expect(trigger, `no select trigger beside the "${label}" label`).toBeTruthy(); + return trigger as HTMLElement; +} + +async function openCreateDialog() { + const posts = serve(); + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: /New datasource/ })); + await screen.findByText('Auto migrate', { selector: 'label' }); + return posts; +} + +/** Opens the select beside `label` and returns its option texts. */ +async function optionTexts(label: string): Promise { + await userEvent.click(triggerUnder(label)); + const options = await screen.findAllByRole('option'); + return options.map((o) => o.textContent ?? ''); +} + +describe('objectui#13353 — an unlabeled driver-config enum shows readable option text', () => { + it('the `autoMigrate` select offers "Off" and "Safe", not the bare values', async () => { + await openCreateDialog(); + const texts = await optionTexts('Auto migrate'); + expect(texts).toEqual(['Off', 'Safe']); + expect(texts, 'no option reads as a bare lowercase enum value').not.toContain('off'); + expect(texts).not.toContain('safe'); + }); + + it('snake, kebab and camel case members each read as words', async () => { + await openCreateDialog(); + expect(await optionTexts('Sync mode')).toEqual(['Read Only', 'Read Write', 'Full Sync']); + }); + + it('the value saved stays the raw enum member', async () => { + const posts = await openCreateDialog(); + fireEvent.change(screen.getByText('Name *', { selector: 'label' }).parentElement!.querySelector('input')!, { + target: { value: 'local' }, + }); + fireEvent.change(screen.getByText('Filename *', { selector: 'label' }).parentElement!.querySelector('input')!, { + target: { value: 'local.db' }, + }); + await userEvent.click(triggerUnder('Auto migrate')); + await userEvent.click(await screen.findByRole('option', { name: 'Safe' })); + await userEvent.click(triggerUnder('Sync mode')); + await userEvent.click(await screen.findByRole('option', { name: 'Full Sync' })); + expect(triggerUnder('Auto migrate'), 'the closed select shows the chosen label').toHaveTextContent('Safe'); + + fireEvent.click(screen.getByRole('button', { name: /Create datasource/ })); + await waitFor(() => expect(posts).toHaveLength(1)); + expect(posts[0].config).toMatchObject({ filename: 'local.db', autoMigrate: 'safe', syncMode: 'fullSync' }); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.selectPlaceholderLocale-11252.test.tsx b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.selectPlaceholderLocale-11252.test.tsx index ee5bd39737..dc53669490 100644 --- a/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.selectPlaceholderLocale-11252.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.selectPlaceholderLocale-11252.test.tsx @@ -110,7 +110,8 @@ describe('objectui#11252 — an unset driver-config enum reads Studio\'s `engine it('CONTROL — an enum with a declared default renders its value, not the placeholder', async () => { const trigger = await openModeSelect('zh-CN', tursoDriver({ default: 'remote' })); - expect(trigger).toHaveTextContent('remote'); + // The option text is built from the raw value since objectui#13353. + expect(trigger).toHaveTextContent('Remote'); expect(trigger).not.toHaveTextContent(t('engine.form.selectEllipsis', 'zh-CN')); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx index 93703aff02..5adff44705 100644 --- a/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx +++ b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx @@ -82,6 +82,7 @@ import { import { createAuthenticatedFetch } from '@object-ui/auth'; import { useMetadataClient } from '../useMetadata.js'; import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js'; +import { humanizeKey } from '../inspectors/json-schema-to-fields.js'; // objectui#7265 — this page used to re-describe the introspection row as a // module-local `interface RemoteTable { name; schema?; columnCount? }`. It is // the SAME wire shape `GET /datasources/:name/remote-tables` returns, and the @@ -237,8 +238,9 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl const metaClient = useMetadataClient(); // objectui#13460 — the page's copy is `engine.datasource.*` in the Studio // table (en-US + zh-CN, the `engine.*` carve-out in `packages/i18n/README.md`). - // Author data (names, driver labels and descriptions, server messages) and the - // driver config schema's enum option values (objectui#13353) stay as served. + // Author data (names, driver labels and descriptions, server messages) stays + // as served; an enum option's text is built from its value (objectui#13353, + // `renderField`). const locale = useMetadataLocale(); /** The locale the memoised loaders below read at call time, so a language switch does not refetch. */ const localeRef = React.useRef(locale); @@ -586,13 +588,18 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl // An unset enum (Turso's optional `mode`, say) shows Studio's own // `engine.form.selectEllipsis`, the word `SchemaForm`'s enum selects // show, in the designer's locale (objectui#11252). + // objectui#13353 — the driver-config schema declares no per-option label, + // so each option's text is built from its raw value with `humanizeKey`, + // the helper `SchemaForm`'s enum selects use (`off` → "Off", + // `read_only` → "Read Only", `fullSync` → "Full Sync"). The value the + // form saves stays the raw enum member. return (