|
| 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 | +}); |
0 commit comments