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 (