diff --git a/.changeset/30327-new-datasource-mode-and-placeholders.md b/.changeset/30327-new-datasource-mode-and-placeholders.md new file mode 100644 index 00000000000..2b4c704a973 --- /dev/null +++ b/.changeset/30327-new-datasource-mode-and-placeholders.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): a new datasource starts on the External schema mode and says what each mode does; a driver box's default is a placeholder, not a value + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Starts on External.** Setup → Datasources → New datasource now starts on `schemaMode: 'external'` (sent with the `external: {}` block the schema requires) instead of `managed`. The page exists to connect a database that already holds tables and sync them in as objects, and only a federation source offers Sync. A managed datasource is ObjectStack's own storage: once an object is stored in it, the schema sync adds the object's columns to an existing table of the same name. The spec's own default (`managed` when a record declares none) is unchanged; only the dialog's starting choice moved. +- **The modes in business words.** The Schema mode select is ordered External, Managed, Validate only, its two main options are relabelled ("External: read an existing database", "Managed: ObjectStack creates and changes the tables"), and a sentence under it says what the chosen mode does to the database and which one to pick to read an existing database. +- **Why a managed row has no Sync.** A managed datasource's row (not the primary `default`) says it has no Sync objects because ObjectStack creates its tables, and how to read an existing database instead. +- **Box defaults are placeholders.** A driver's text or number box with a default (SQLite `Filename` `:memory:`, PostgreSQL `Host` `localhost` and `Port` `5432`, and so on) starts empty with the default as its placeholder, so a typed path is no longer appended to `:memory:`. A new datasource's Test and Create still send the default for a box left empty, so the request is what it was; an edit shows and sends the stored values. Switches and selects keep their defaults as values. +- **The widening.** Four new `studio` namespace rows in `@object-ui/i18n` (en and zh): `engine.datasource.schemaModeExternalHint`, `engine.datasource.schemaModeManagedHint`, `engine.datasource.schemaModeValidateOnlyHint` and `engine.datasource.noSyncManaged`; the en and zh text of `engine.datasource.schemaModeExternal` and `engine.datasource.schemaModeManaged` changes. diff --git a/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.externalBlock-11368.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.externalBlock-11368.test.tsx index 4d904e6f8e2..5853be2f4ba 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.externalBlock-11368.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.externalBlock-11368.test.tsx @@ -285,7 +285,8 @@ async function createThroughThePage( if (opts.database !== '') type('Database', opts.database ?? 'wh'); type('Username', 'reader'); if (opts.secret) type('Password', opts.secret); - if (opts.mode !== 'managed') await chooseMode(opts.mode); + // A new datasource starts on External (objectstack#30327); every other mode is chosen. + if (opts.mode !== 'external') await chooseMode(opts.mode); return saveAndSettle(door, 'POST', /Create datasource/); } diff --git a/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.locale-13460.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.locale-13460.test.tsx index 76250ae3d9a..b695158b77f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.locale-13460.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.locale-13460.test.tsx @@ -75,7 +75,7 @@ describe('Setup → Datasources reads engine.datasource.* (objectui#13460)', () 'engine.datasource.new', 'engine.datasource.empty', 'engine.datasource.statusOk', - 'engine.datasource.schemaModeManaged', + 'engine.datasource.schemaModeExternal', 'engine.datasource.create', ]) { const zh = t(key, ZH); @@ -114,7 +114,8 @@ describe('Setup → Datasources reads engine.datasource.* (objectui#13460)', () expect(within(dialog).getByText(t('engine.datasource.newDescription', ZH))).toBeTruthy(); expect(within(dialog).getByText(`${t('engine.datasource.name', ZH)} *`)).toBeTruthy(); expect(within(dialog).getByText(t('engine.datasource.schemaMode', ZH))).toBeTruthy(); - expect(within(dialog).getByText(t('engine.datasource.schemaModeManaged', ZH))).toBeTruthy(); + // A new datasource starts on External (objectstack#30327). + expect(within(dialog).getByText(t('engine.datasource.schemaModeExternal', ZH))).toBeTruthy(); expect(within(dialog).getByRole('button', { name: t('engine.datasource.create', ZH) })).toBeTruthy(); expect(within(dialog).getByRole('button', { name: t('engine.cancel', ZH) })).toBeTruthy(); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.newDatasourceModes-30327.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.newDatasourceModes-30327.test.tsx new file mode 100644 index 00000000000..f2d2f50ac38 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.newDatasourceModes-30327.test.tsx @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30327 and the ui half of objectstack#30331 — the New datasource + * dialog. + * + * #30327: the dialog started on the managed schema mode, which has no Sync and + * whose schema sync adds an object's columns to an existing table of the same + * name, on a page whose purpose is to read an existing database. Nothing said + * what either mode does, and a managed row just had no Sync button. + * #30331: a text box's default (SQLite's `:memory:`) was the box's VALUE, read + * as a hint, so a typed path was appended to it (`:memory:/tmp/parts.db`). + * + * What is pinned: + * - (a) a new datasource starts on External, the select says what External + * does and when to pick it, and Create sends `schemaMode: 'external'` with + * the `external: {}` block the schema requires; + * - (b) choosing Managed swaps the sentence for the managed one, and Create + * sends `managed` with no block; + * - (c) a text or number box with a default starts empty with the default as + * its placeholder; a typed path is the whole value, and Test and Create + * send exactly it; + * - (d) CONTROL: a box left empty sends its default, so the request is what + * it was; a switch keeps its default as its value; + * - (e) an edit shows the stored values, no placeholder, and sends them as + * they stand; + * - (f) a managed row says why it has no Sync; an external row and the + * primary datasource say nothing; + * - (g) zh: the sentences read in Chinese. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@object-ui/i18n'; +import { toast } from 'sonner'; +import { t } from '../i18n.js'; + +type Reply = { ok: boolean; status: number; text: () => Promise }; + +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 { DatasourceResourcePage } from './DatasourceResourcePage'; + +// The zh rows (g) reads live in the `studio` namespace's lazily fetched `zh` +// catalogue; resident before any case, so they read synchronously. +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; +await loadStudioCatalogue('zh'); + +afterEach(() => { + cleanup(); + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); +}); + +const reply = (status: number, payload: unknown): Reply => ({ + ok: status < 400, + status, + text: async () => JSON.stringify(payload), +}); +const ok = (data: unknown, status = 200) => reply(status, { success: true, data }); + +/** The catalog's SQLite entry as `GET /datasources/drivers` serves it: a defaulted text box and a defaulted switch. */ +const SQLITE = { + id: 'sqlite', + label: 'SQLite', + configSchema: { + properties: { + filename: { type: 'string', title: 'Filename', default: ':memory:', description: 'Database file path, or ":memory:" for an ephemeral database' }, + wal: { type: 'boolean', title: 'WAL', default: true }, + }, + }, +}; +/** A defaulted number box beside a defaulted text box. */ +const POSTGRES = { + id: 'postgres', + label: 'PostgreSQL', + configSchema: { + properties: { + host: { type: 'string', title: 'Host', default: 'localhost' }, + port: { type: 'integer', title: 'Port', default: 5432 }, + database: { type: 'string', title: 'Database' }, + }, + }, +}; + +interface Seen { method: string; url: string; body?: Record } + +function renderPage(opts: { rows?: unknown[]; stored?: Record; language?: string } = {}) { + const seen: Seen[] = []; + state.fetch = (async (url: string, init?: RequestInit) => { + const method = (init?.method ?? 'GET').toUpperCase(); + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as Record) : undefined; + seen.push({ method, url, body }); + if (url === '/api/v1/datasources/drivers') return ok({ drivers: [SQLITE, POSTGRES] }); + if (url === '/api/v1/datasources' && method === 'GET') return ok({ datasources: opts.rows ?? [] }); + if (url === '/api/v1/datasources' && method === 'POST') return ok({ datasource: { name: body?.name } }, 201); + if (url === '/api/v1/datasources/test') return ok({ result: { ok: true } }); + if (opts.stored && url === `/api/v1/datasources/${String(opts.stored.name)}` && method === 'GET') { + return ok({ datasource: opts.stored }); + } + if (opts.stored && url === `/api/v1/datasources/${String(opts.stored.name)}` && method === 'PATCH') { + return ok({ datasource: { name: opts.stored.name } }); + } + return reply(404, { success: false, error: { code: 'NOT_FOUND', message: `unexpected ${method} ${url}` } }); + }) as typeof state.fetch; + render( + + + , + ); + return seen; +} + +/** The input or select trigger beside a form label (the page's labels carry no `htmlFor`). */ +function controlUnder(label: string | RegExp): HTMLElement { + const lab = screen.getByText(label, { selector: 'label' }); + // A switch sits beside the div that holds its label; every other control under it. + const control = lab.parentElement?.querySelector('input, [role="combobox"], [role="switch"]') + ?? lab.parentElement?.parentElement?.querySelector('[role="switch"]'); + expect(control, `no control beside the "${String(label)}" label`).toBeTruthy(); + return control as HTMLElement; +} + +async function openNew(language = 'en') { + fireEvent.click(await screen.findByRole('button', { name: t('engine.datasource.new', language === 'zh' ? 'zh-CN' : 'en-US') })); + return screen.findByRole('dialog'); +} + +async function chooseDriver(label: string) { + await userEvent.click(controlUnder(/^Driver/)); + await userEvent.click(await screen.findByRole('option', { name: label })); +} + +async function chooseMode(name: RegExp) { + await userEvent.click(controlUnder('Schema mode')); + await userEvent.click(await screen.findByRole('option', { name })); +} + +const lastWrite = (seen: Seen[], method: string, url: string) => { + const hits = seen.filter((r) => r.method === method && r.url === url); + expect(hits.length, `a ${method} ${url} was sent`).toBeGreaterThan(0); + return hits[hits.length - 1].body ?? {}; +}; + +const EN = 'en-US'; +const ZH = 'zh-CN'; + +describe('New datasource — starts on External and says what each schema mode does (objectstack#30327)', () => { + it('(a) starts on External with its sentence, and Create sends external with the block', async () => { + const seen = renderPage(); + const dialog = await openNew(); + expect(within(controlUnder('Schema mode')).getByText(t('engine.datasource.schemaModeExternal', EN))).toBeTruthy(); + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeExternalHint', EN)); + fireEvent.change(controlUnder('Name *'), { target: { value: 'parts_db' } }); + fireEvent.change(controlUnder('Filename'), { target: { value: '/srv/data/parts.db' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + const post = lastWrite(seen, 'POST', '/api/v1/datasources'); + expect(post).toMatchObject({ name: 'parts_db', driver: 'sqlite', schemaMode: 'external', config: { filename: '/srv/data/parts.db' } }); + expect(post.external).toEqual({}); + }); + + it('(b) Managed swaps the sentence, and Create sends managed with no block', async () => { + const seen = renderPage(); + const dialog = await openNew(); + await chooseMode(/^Managed/); + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeManagedHint', EN)); + await chooseMode(/^Validate only/); + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeValidateOnlyHint', EN)); + await chooseMode(/^Managed/); + fireEvent.change(controlUnder('Name *'), { target: { value: 'scratch' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + const post = lastWrite(seen, 'POST', '/api/v1/datasources'); + expect(post.schemaMode).toBe('managed'); + expect('external' in post).toBe(false); + }); +}); + +describe('New datasource — a box default is a placeholder, not a value (objectstack#30331)', () => { + it('(c) Filename starts empty with :memory: as its placeholder; a typed path is the whole value', async () => { + const seen = renderPage(); + const dialog = await openNew(); + const filename = controlUnder('Filename') as HTMLInputElement; + expect(filename.value).toBe(''); + expect(filename.placeholder).toBe(':memory:'); + await userEvent.type(filename, '/srv/data/parts.db'); + expect(filename.value).toBe('/srv/data/parts.db'); + fireEvent.click(within(dialog).getByRole('button', { name: /Test connection/ })); + await waitFor(() => expect(seen.some((r) => r.url === '/api/v1/datasources/test')).toBe(true)); + expect(lastWrite(seen, 'POST', '/api/v1/datasources/test')).toMatchObject({ driver: 'sqlite', config: { filename: '/srv/data/parts.db', wal: true } }); + }); + + it('(d) CONTROL: boxes left empty send their defaults; a switch keeps its default as its value', async () => { + const seen = renderPage(); + const dialog = await openNew(); + expect((controlUnder('WAL')).getAttribute('aria-checked')).toBe('true'); + fireEvent.click(within(dialog).getByRole('button', { name: /Test connection/ })); + await waitFor(() => expect(seen.some((r) => r.url === '/api/v1/datasources/test')).toBe(true)); + expect(lastWrite(seen, 'POST', '/api/v1/datasources/test').config).toEqual({ filename: ':memory:', wal: true }); + + await chooseDriver('PostgreSQL'); + const host = controlUnder('Host') as HTMLInputElement; + const port = controlUnder('Port') as HTMLInputElement; + expect([host.value, host.placeholder]).toEqual(['', 'localhost']); + expect([port.value, port.placeholder]).toEqual(['', '5432']); + fireEvent.change(controlUnder('Name *'), { target: { value: 'warehouse' } }); + fireEvent.change(controlUnder('Database'), { target: { value: 'wh' } }); + // Typed then cleared counts as empty. + fireEvent.change(host, { target: { value: 'db' } }); + fireEvent.change(host, { target: { value: '' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + expect(lastWrite(seen, 'POST', '/api/v1/datasources').config).toEqual({ host: 'localhost', port: 5432, database: 'wh' }); + }); + + it('(e) an edit shows the stored values with no placeholder, and sends them as they stand', async () => { + const stored = { name: 'parts_db', driver: 'sqlite', schemaMode: 'external', config: { filename: '/srv/data/parts.db' }, origin: 'runtime', hasSecret: false }; + const seen = renderPage({ rows: [{ name: 'parts_db', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'external' }], stored }); + fireEvent.click(await screen.findByTitle(t('engine.datasource.edit', EN))); + const dialog = await screen.findByRole('dialog'); + const filename = controlUnder('Filename') as HTMLInputElement; + expect(filename.value).toBe('/srv/data/parts.db'); + expect(filename.placeholder).toBe(''); + fireEvent.click(within(dialog).getByRole('button', { name: /Save changes/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + expect(lastWrite(seen, 'PATCH', '/api/v1/datasources/parts_db').config).toEqual({ filename: '/srv/data/parts.db' }); + }); +}); + +describe('A managed row says why it has no Sync (objectstack#30327)', () => { + const rows = [ + { name: 'default', driver: 'sqlite', origin: 'code', status: 'ok', schemaMode: 'managed' }, + { name: 'scratch', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'managed' }, + { name: 'parts_db', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'external' }, + ]; + const rowOf = (name: string) => { + const row = screen.getByText(name, { selector: 'code' }).closest('div.flex.items-center.gap-3'); + if (!(row instanceof HTMLElement)) throw new Error(`no ${name} row`); + return row; + }; + + it('(f) the managed row carries the note; the external row and the primary datasource do not', async () => { + renderPage({ rows }); + await screen.findByText('scratch', { selector: 'code' }); + expect(within(rowOf('scratch')).getByTestId('datasource-no-sync-note').textContent).toBe(t('engine.datasource.noSyncManaged', EN)); + expect(within(rowOf('scratch')).queryByRole('button', { name: t('engine.datasource.sync', EN) })).toBeNull(); + expect(within(rowOf('parts_db')).queryByTestId('datasource-no-sync-note')).toBeNull(); + expect(within(rowOf('parts_db')).getByRole('button', { name: t('engine.datasource.sync', EN) })).toBeTruthy(); + expect(within(rowOf('default')).queryByTestId('datasource-no-sync-note')).toBeNull(); + }); + + it('(g) zh: the mode sentences and the note read in Chinese', async () => { + for (const key of [ + 'engine.datasource.schemaModeExternal', + 'engine.datasource.schemaModeManaged', + 'engine.datasource.schemaModeExternalHint', + 'engine.datasource.schemaModeManagedHint', + 'engine.datasource.schemaModeValidateOnlyHint', + 'engine.datasource.noSyncManaged', + ]) { + expect(t(key, ZH), key).toMatch(/\p{Script=Han}/u); + expect(t(key, ZH), key).not.toBe(t(key, EN)); + } + renderPage({ rows, language: 'zh' }); + await screen.findByText('scratch', { selector: 'code' }); + expect(within(rowOf('scratch')).getByTestId('datasource-no-sync-note').textContent).toBe(t('engine.datasource.noSyncManaged', ZH)); + const dialog = await openNew('zh'); + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeExternalHint', ZH)); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx index 66e3e45a892..8b60a9f598b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx @@ -26,12 +26,17 @@ * `secret` so they are encrypted into `sys_secret` and never persisted in * `config` metadata. Edit prefills from `GET /datasources/:name` (which * returns `config` + a `hasSecret` flag, never the credential itself). + * A new datasource starts on the `external` schema mode, and the select + * says what the chosen mode does to the database; a text or number box + * shows its default as a placeholder, sent when left empty + * (objectstack#30327, #30331). * - **Test** a connection (saved → `POST …/:name/test`; draft in the editor → * `POST …/test`). * - **Sync objects**: introspect remote tables (`GET …/remote-tables`), pick * which to import, generate an object definition for each (`POST …/object-draft`), * and create it through the normal metadata channel (`MetadataClient.save('object', …)`). - * Offered only on a federation source (`offersSync`, objectui#13318). Once + * Offered only on a federation source (`offersSync`, objectui#13318); a + * managed row says why it has none (`explainsNoSync`). Once * an object is created the dialog stays open on the result: it links each * new object and says what is left before people can use it, adding it to * an app and granting access (objectstack#30328). @@ -264,13 +269,29 @@ interface EditorForm { secret: string; } -/** The Schema mode select's options, in its order; each label is an `engine.datasource.*` row (objectui#13460). */ +/** + * The Schema mode select's options, in its order; each label is an + * `engine.datasource.*` row (objectui#13460), and each `hintKey` is the + * sentence under the select that says, in business words, what the selected + * mode does to the database and when to pick it (objectstack#30327). + * + * `external` comes first and is what a new datasource starts on: the page + * exists to connect a database that already holds tables and sync them in as + * objects, and only a federation source offers Sync (`offersSync`). A managed + * datasource is ObjectStack's own storage: when an object is stored in it, the + * schema sync creates the object's table, or ADDS the object's missing + * columns to an existing table of the same name (driver-sql `initObjects`), + * which on an existing database changes a customer's tables. + */ const SCHEMA_MODES = [ - { value: 'managed', labelKey: 'engine.datasource.schemaModeManaged' }, - { value: 'external', labelKey: 'engine.datasource.schemaModeExternal' }, - { value: 'validate-only', labelKey: 'engine.datasource.schemaModeValidateOnly' }, + { value: 'external', labelKey: 'engine.datasource.schemaModeExternal', hintKey: 'engine.datasource.schemaModeExternalHint' }, + { value: 'managed', labelKey: 'engine.datasource.schemaModeManaged', hintKey: 'engine.datasource.schemaModeManagedHint' }, + { value: 'validate-only', labelKey: 'engine.datasource.schemaModeValidateOnly', hintKey: 'engine.datasource.schemaModeValidateOnlyHint' }, ] as const; +/** The `schemaMode` a new datasource starts on (objectstack#30327, see {@link SCHEMA_MODES}). */ +const NEW_DATASOURCE_SCHEMA_MODE = 'external'; + /** * The platform's primary datasource, the name the server's * `DEFAULT_DATASOURCE_NAME` holds. Every Sync door refuses it by name, @@ -297,21 +318,62 @@ function offersSync(row: DatasourceRow): boolean { return row.schemaMode !== undefined && FEDERATION_SCHEMA_MODES.has(row.schemaMode); } +/** + * Does this row say why it has no "Sync objects"? (objectstack#30327) + * + * A managed datasource, declared or by default, is ObjectStack's own storage: + * it has no remote tables to bring in, and the Sync doors refuse it. The row + * says so instead of leaving the button out with no word. The primary + * datasource is the platform's own and says nothing. + */ +function explainsNoSync(row: DatasourceRow): boolean { + if (row.name === PRIMARY_DATASOURCE) return false; + return row.schemaMode === undefined || row.schemaMode === 'managed'; +} + const SERVER = ((import.meta as { env?: Record }).env?.VITE_SERVER_URL || '').replace(/\/+$/, ''); const isPassword = (p: JsonProp) => p.format === 'password'; -/** Build the default config (password props excluded — they live in `secret`). */ +/** Is this prop drawn as a text or number box (not a switch, a select or the credential)? */ +const isTypedBox = (p: JsonProp) => !isPassword(p) && p.type !== 'boolean' && !Array.isArray(p.enum); + +/** + * Build the default config (password props excluded — they live in `secret`). + * + * A text or number box starts EMPTY, its default shown as the box's + * placeholder instead (objectstack#30331): a default held as the box's value + * was taken for a hint, and typing a path into the SQLite Filename box + * appended it to `:memory:`. {@link withBoxDefaults} sends that default when + * the box is still empty, so a new datasource's request is what it was. + * A switch or a select keeps its default as its value: it has no text to + * type after. + */ function defaultConfig(driver: DriverEntry | undefined): Record { const props = driver?.configSchema?.properties ?? {}; const cfg: Record = {}; for (const [key, prop] of Object.entries(props)) { - if (isPassword(prop)) continue; + if (isPassword(prop) || isTypedBox(prop)) continue; if (prop.default !== undefined) cfg[key] = prop.default; } return cfg; } +/** + * A new datasource's config with each still-empty text or number box filled + * with its default, the value its placeholder shows (objectstack#30331). + * An edit sends the config as it stands: its boxes hold the stored values. + */ +function withBoxDefaults(driver: DriverEntry | undefined, config: Record): Record { + const props = driver?.configSchema?.properties ?? {}; + const cfg: Record = { ...config }; + for (const [key, prop] of Object.entries(props)) { + if (!isTypedBox(prop) || prop.default === undefined) continue; + if (cfg[key] === undefined || cfg[key] === '') cfg[key] = prop.default; + } + return cfg; +} + export function DatasourceResourcePage(_props: { type?: string }): React.ReactElement { const authFetch = React.useMemo(() => createAuthenticatedFetch(), []); const metaClient = useMetadataClient(); @@ -473,6 +535,9 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl } }, [api, drivers]); + /** The sentence under the Schema mode select for the mode now selected (objectstack#30327). */ + const selectedModeHint = SCHEMA_MODES.find((m) => m.value === form?.schemaMode)?.hintKey; + const selectedDriver = React.useMemo( () => drivers.find((d) => d.id === form?.driver), [drivers, form?.driver], @@ -488,7 +553,7 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl name: '', label: '', driver: first?.id ?? '', - schemaMode: 'managed', + schemaMode: NEW_DATASOURCE_SCHEMA_MODE, storedSchemaMode: null, config: defaultConfig(first), secret: '', @@ -557,7 +622,7 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl label: f.label || undefined, driver: f.driver, schemaMode: f.schemaMode, - config: f.config, + config: requestConfig(f), }; if (f.schemaMode !== 'managed' && (f.storedSchemaMode === null || f.storedSchemaMode === 'managed')) { body.external = {}; @@ -566,12 +631,16 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl return body; }; + /** The config a request sends: a new datasource's empty boxes take their defaults (`withBoxDefaults`). */ + const requestConfig = (f: EditorForm) => + f.storedSchemaMode === null ? withBoxDefaults(drivers.find((d) => d.id === f.driver), f.config) : f.config; + const testDraft = async () => { if (!form) return; setTestingDraft(true); setTestMsg(null); try { - const r = await api('/api/v1/datasources/test', { method: 'POST', body: JSON.stringify({ driver: form.driver, config: form.config, ...(form.secret ? { secret: form.secret } : {}) }) }); + const r = await api('/api/v1/datasources/test', { method: 'POST', body: JSON.stringify({ driver: form.driver, config: requestConfig(form), ...(form.secret ? { secret: form.secret } : {}) }) }); const res = r?.result ?? r; if (res?.ok === false) setTestMsg({ ok: false, text: res?.error ?? t('engine.datasource.connectionFailed', locale) }); else if (res?.latencyMs != null) setTestMsg({ ok: true, text: tFormat('engine.datasource.connectionOkLatency', locale, { latency: res.latencyMs }) }); @@ -735,6 +804,8 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl setConfigValue(key, isNum ? (e.target.value === '' ? undefined : Number(e.target.value)) : e.target.value)} /> {prop.description &&

{prop.description}

} @@ -797,6 +868,11 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl {ds.statusReason} )} + {explainsNoSync(ds) && ( +
+ {t('engine.datasource.noSyncManaged', locale)} +
+ )}