Skip to content

Commit 0d54279

Browse files
fix(app-shell): a new datasource starts on External and says what each schema mode does; a driver box's default is a placeholder, not a value (#30561)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu7w9o9pLtsBhm1Tizv1HL6i)_ Fixes #30327 Fixes #30331 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:设置 → 数据源 → 新建数据源,结构模式默认改为「外部:读取现有数据库」,选择框下用业务语言说明所选模式对数据库做什么、读现有库该选哪个;托管数据源的行写明为什么没有「同步对象」;驱动表单里带默认值的文本框(SQLite 的 Filename 等)改为占位提示,不再预填成实际值。 - 为什么改:原来默认托管,托管数据源不能同步现有表,也没有任何说明;实测托管模式下给对象绑定同名表时会给客户表加 9 列,带 `_id` 的表会被删表重建、数据丢失。Filename 预填 `:memory:`,用户输入路径会拼成 `:memory:/…`。 - 风险与回滚:只改前端:数据源页面、studio 词条(新增 4 行、改 2 行,en/zh)。spec 的默认值(未声明即 managed)不变;留空的框仍发原来的默认值,请求与原来一致。回滚即还原本 PR,无数据迁移。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx`(`SCHEMA_MODES`、`defaultConfig` / `withBoxDefaults`、`explainsNoSync`)、`ui/packages/i18n/src/namespaces/studio/zh.ts` 的 `engine.datasource.schemaMode*` 与 `noSyncManaged`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/datasource/`,预期 9 个文件全绿(新文件 7 个用例)。 - 需要你做的:无,席位落地。 ## What changes Cards #30327 and the **domain:ui half** of #30331 (its server half, the test and save doors refusing a SQLite filename that is neither `:memory:` nor a usable path, landed in #30395). Both are the New datasource dialog in `DatasourceResourcePage.tsx`. - **A new datasource starts on External** (`NEW_DATASOURCE_SCHEMA_MODE`), sent with the `external: {}` block the schema requires (the existing `draftBody` rule). The page says it connects external databases and syncs their tables in as objects; only a federation source offers Sync. The spec's default (`managed` when a record declares none) is unchanged; only the dialog's starting choice moved. No reason was found that a new datasource needs managed: see *What managed does to a source database* below. - **The modes in business words.** The select is ordered External, Managed, Validate only; the two main options are relabelled ("External: read an existing database", "Managed: ObjectStack creates and changes the tables"), and a sentence under the select (`data-testid="datasource-schema-mode-hint"`) says what the chosen mode does and which one reads an existing database. - **A managed row says why it has no Sync** (`explainsNoSync`): any managed row except the primary `default` carries "No Sync objects: this datasource is managed, so ObjectStack creates its tables and there are none to bring in. To read an existing database's tables, create a datasource for it with the schema mode External." It points to a new datasource rather than an edit to External, because what a switch does to objects already stored on a managed datasource was not measured. - **A box default is a placeholder** (#30331 ui half). `defaultConfig` no longer seeds a text or number box with its JSON-Schema default; the box starts empty with the default as its placeholder (`:memory:` for SQLite Filename). `withBoxDefaults` fills a still-empty box (or one typed then cleared) with its default for a new datasource's Test and Create, so the request is byte-for-byte what it was. This applies to every defaulted text or number box, not only Filename (PostgreSQL / MySQL / Mongo `host` and `port`, PostgreSQL `schema`): they had the same append trap, and their request is equally unchanged. An edit shows the stored values with no placeholder and sends them as they stand. Switches and selects keep their default as their value. - **Widening:** four new `studio` rows in `@object-ui/i18n` (en and zh): `engine.datasource.schemaModeExternalHint`, `schemaModeManagedHint`, `schemaModeValidateOnlyHint`, `noSyncManaged`; the en and zh text of `schemaModeExternal` and `schemaModeManaged` changes. The `studio` namespace ships en and zh only by its recorded ruling. - Changeset `.changeset/30327-new-datasource-mode-and-placeholders.md`, `@object-ui/app-shell` and `@object-ui/i18n` patch. Branch: the harness-assigned `claude/project-thread-5qt4jg`, not `claude/issue-30327-*`; the cards are #30327 and #30331. ### What managed does to a source database (the card's first question) Measured on the live rig (base `8e5fabe5`): a SQLite file with `parts (part_no, part_name, qty)`, 2 rows, and `legacy_items (_id, title)`, 2 rows. Registered once as a **managed** runtime datasource and once (a copy) as an **external** one, then an object bound to each table (`PUT /api/v1/meta/object/NAME` with `datasource`). - **Managed:** `parts` gained 9 columns (`organization_id`, `created_at`, `created_by`, `updated_at`, `updated_by`, `owner_id`, `owning_business_unit_id`, `queue`, `__search`), rows kept. `legacy_items` was **dropped and recreated**: 11 platform columns, `_id` gone, **0 rows** (2 before). Code: driver-sql `initObjects` (`sql-driver-rotation.ts`) drops a table that has `_id` and no `id`, and `ALTER TABLE ADD COLUMN`s every declared field an existing table lacks. A managed datasource is a DDL target by ADR-0015 §4.1 ("ObjectStack owns this schema: DDL + migrations allowed"). - **External (control):** both tables unchanged, same columns and rows; the object drafts carry `external.remoteName` and are bound DDL-free (`registerExternalObject`). So binding an object on a managed datasource does alter a customer's tables, and can lose rows. That is why the dialog now starts on External, and why the managed sentence says to use it only for an empty database set aside for ObjectStack. The drop is a defect of its own, reported for the seat to file (see Acceptance notes). ## Tests - `DatasourceResourcePage.newDatasourceModes-30327.test.tsx` (new, 7 cases; expectations read from Studio's table, never retyped): - (a) a new datasource starts on External with its sentence; Create sends `schemaMode: 'external'` with `external: {}` and the typed filename; - (b) choosing Managed / Validate only swaps the sentence; Create in Managed sends no `external`; - (c) Filename starts empty with `:memory:` as its placeholder; typing gives exactly the typed path; Test sends it; - (d) CONTROL: boxes left empty (and one typed then cleared) send their defaults (`filename: ':memory:'`, `host: 'localhost'`, `port: 5432`); a switch keeps its default (`wal: true`); - (e) an edit shows the stored value with no placeholder and PATCHes it as it stands; - (f) a managed row carries the note and no Sync button; an external row has Sync and no note; the primary `default` has no note; - (g) zh: the six rows are real Han rows differing from en; the note and the External sentence render in Chinese. - Moved pins (the default moved, deliberately): `externalBlock-11368` now chooses a mode unless it is External (it used to skip choosing for Managed); `locale-13460` reads the External option on the new dialog instead of Managed. ## Acceptance notes - **Driver form labels are server-side** (the card's last Expected item). `Filename`, `Auto-migrate schema`, `Host`, `Port` and the field descriptions (`Database file path, or ":memory:" for an ephemeral database`) are the JSON-Schema `title` / `description` of the driver config Zod schemas in `packages/spec/src/data/driver/*.zod.ts` (`.meta({ title })`, `.describe()`), served by `GET /api/v1/datasources/drivers` (`packages/services/service-datasource/src/driver-catalog.ts`), as is the driver's description (`File-backed (or in-memory) SQL database…`). Measured live: the served sqlite entry carries `"title": "Filename"`, `"title": "Auto-migrate schema"`. Per the dispatch, `packages/**` is untouched; the seat files a `domain:server` card. The console does not translate them by key on its side, which would be a consumer-side dictionary of a producer's strings. - **Defect, for the seat to file (class a):** storing an object on a managed SQL datasource whose existing same-named table has `_id` and no `id` drops that table and recreates it empty (driver-sql `initObjects`, `sql-driver-rotation.ts`, the `existingColumns.includes('_id') && !existingColumns.includes('id')` branch). reach: `PUT /api/v1/meta/object/legacy_items` with `datasource: customer_managed` (runtime managed sqlite datasource on an existing file), 2 rows before, 0 after. Dedupe words: managed datasource drop table _id, initObjects legacy _id rebuild, schema sync data loss. - **validate-only vs external, noted not filed:** the spec describes `validate-only` as "Like external, but mismatches warn instead of fail", but no runtime code distinguishes the two (the boot validation reads `external.validation.onMismatch` only). The Validate only sentence therefore claims only what holds: it reads an existing database like External and never changes its tables. Not measured live. - The managed sentence says storing an object "adds columns to the table of the same name"; it does not describe the drop, which is a defect to fix rather than a behaviour to document. ## Verification Code head `21745ff7` (base `8e5fabe5`); the PR head adds an evidence commit (`83b029a7`) and its removal, tree identical to `21745ff7`. <details><summary>Live rig and screenshots (zh-CN, 1440 wide)</summary> `examples/app-showcase` `objectstack dev --seed-admin` on a private port with a file platform database, the console via `vite` in `ui/apps/console` with `DEV_PROXY_TARGET`, Chromium at browser locale zh-CN. Before shots: the page and the two studio tables restored to the base with `git restore --source=8e5fabe5` on a freshly started vite; after shots: `HEAD` restored and vite restarted (served module confirmed by grepping `NEW_DATASOURCE_SCHEMA_MODE`). Before (main): the dialog opens on 托管(表结构由 ObjectStack 管理), Filename holds `:memory:` as its value, typing a path gives `:memory:/srv/data/parts.db`, and the managed row `customer_managed` has no Sync and no word. ![before list](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/before-1-list-zh-1440.png?raw=true) ![before dialog](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/before-2-new-dialog-zh-1440.png?raw=true) ![before typed path](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/before-3-typed-path-zh-1440.png?raw=true) After: the dialog opens on 外部:读取现有数据库 with its sentence; Filename is empty with `:memory:` as placeholder, typing gives `/srv/data/parts.db`; choosing 托管 shows the managed sentence; the managed row carries the note. A datasource created through the dialog with only Name and Filename filled was sent `schemaMode: 'external'`, `external: {}`, `config.filename` exactly the typed path; its row offers 同步对象, which lists `parts` and `legacy_items`; the source file was unchanged. ![after list](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/after-1-list-zh-1440.png?raw=true) ![after dialog](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/after-2-new-dialog-zh-1440.png?raw=true) ![after typed path](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/after-3-typed-path-zh-1440.png?raw=true) ![after managed chosen](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/after-4-managed-chosen-zh-1440.png?raw=true) ![after created external, sync](https://github.com/objectstack-ai/objectstack/blob/83b029a77122673281c72d3d2ecdac2bc0d3ecef/evidence/30327/after-5-created-external-sync-zh-1440.png?raw=true) </details> <details><summary>Tests, ablation, type-check, lint, gates</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/datasource packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.rollbackConfirm-13333.test.tsx packages/i18n` on `ce7fe5a1` (source identical to `21745ff7`): Test Files 105 passed (105), Tests 1509 passed, 13 skipped; lock VERDICT command-exit 0. - Ablations (`scripts/ablation-replace.mjs`, each anchor x1 to x0, each restored with blob equal to HEAD and `git diff HEAD` empty), against the new test file: - start on `managed`: 2 failed / 5 passed ((a), (g)); - seed box defaults as values again: 2 failed / 5 passed ((c), (d)); - `explainsNoSync` returns false: 2 failed / 5 passed ((f), (g)); - empty box sends nothing: 1 failed / 6 passed ((d)). - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n` (`tsc --noEmit` and `tsc -p tsconfig.test.json`): 69 tasks successful, lock VERDICT command-exit 0. - eslint on the 6 changed source and test files: 0 errors (2 pre-existing warnings on untouched lines). - `node scripts/pm/dispatch-gates.mjs --commands`: 35 derived families, each exit 0; `--ran`: 35 derived, 35 run, 0 NOT-MEASURED (derived zero). - The `Type Check UI` job's 21 ui checks run locally (`type-check:coverage`, `check:phantom-deps` … `check:spec-symbols` … `check:i18n-keys`, `check:i18n-drift`, `type-check:scripts`, `type-check:vitest-config`, `type-check:e2e`, `type-check:vitest-setup`): exit 0 each. `check:new-line-citations`: 0 new. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; lint passes on the changed files. - [x] 4. One issue pair, one branch off an up-to-date `main`, one pull request. - [x] 5. The changeset carries both declaration lines, as does this body. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the fleet's required Chinese summary). --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgTJT1f9c473zqg7Zhd)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgTJT1f9c473zqg7Zhd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bcdb1db commit 0d54279

7 files changed

Lines changed: 409 additions & 17 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
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
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **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.
12+
- **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.
13+
- **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.
14+
- **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.
15+
- **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.

‎ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.externalBlock-11368.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,8 @@ async function createThroughThePage(
285285
if (opts.database !== '') type('Database', opts.database ?? 'wh');
286286
type('Username', 'reader');
287287
if (opts.secret) type('Password', opts.secret);
288-
if (opts.mode !== 'managed') await chooseMode(opts.mode);
288+
// A new datasource starts on External (objectstack#30327); every other mode is chosen.
289+
if (opts.mode !== 'external') await chooseMode(opts.mode);
289290
return saveAndSettle(door, 'POST', /Create datasource/);
290291
}
291292

‎ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.locale-13460.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe('Setup → Datasources reads engine.datasource.* (objectui#13460)', ()
7575
'engine.datasource.new',
7676
'engine.datasource.empty',
7777
'engine.datasource.statusOk',
78-
'engine.datasource.schemaModeManaged',
78+
'engine.datasource.schemaModeExternal',
7979
'engine.datasource.create',
8080
]) {
8181
const zh = t(key, ZH);
@@ -114,7 +114,8 @@ describe('Setup → Datasources reads engine.datasource.* (objectui#13460)', ()
114114
expect(within(dialog).getByText(t('engine.datasource.newDescription', ZH))).toBeTruthy();
115115
expect(within(dialog).getByText(`${t('engine.datasource.name', ZH)} *`)).toBeTruthy();
116116
expect(within(dialog).getByText(t('engine.datasource.schemaMode', ZH))).toBeTruthy();
117-
expect(within(dialog).getByText(t('engine.datasource.schemaModeManaged', ZH))).toBeTruthy();
117+
// A new datasource starts on External (objectstack#30327).
118+
expect(within(dialog).getByText(t('engine.datasource.schemaModeExternal', ZH))).toBeTruthy();
118119
expect(within(dialog).getByRole('button', { name: t('engine.datasource.create', ZH) })).toBeTruthy();
119120
expect(within(dialog).getByRole('button', { name: t('engine.cancel', ZH) })).toBeTruthy();
120121
});
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#30327 and the ui half of objectstack#30331 — the New datasource
5+
* dialog.
6+
*
7+
* #30327: the dialog started on the managed schema mode, which has no Sync and
8+
* whose schema sync adds an object's columns to an existing table of the same
9+
* name, on a page whose purpose is to read an existing database. Nothing said
10+
* what either mode does, and a managed row just had no Sync button.
11+
* #30331: a text box's default (SQLite's `:memory:`) was the box's VALUE, read
12+
* as a hint, so a typed path was appended to it (`:memory:/tmp/parts.db`).
13+
*
14+
* What is pinned:
15+
* - (a) a new datasource starts on External, the select says what External
16+
* does and when to pick it, and Create sends `schemaMode: 'external'` with
17+
* the `external: {}` block the schema requires;
18+
* - (b) choosing Managed swaps the sentence for the managed one, and Create
19+
* sends `managed` with no block;
20+
* - (c) a text or number box with a default starts empty with the default as
21+
* its placeholder; a typed path is the whole value, and Test and Create
22+
* send exactly it;
23+
* - (d) CONTROL: a box left empty sends its default, so the request is what
24+
* it was; a switch keeps its default as its value;
25+
* - (e) an edit shows the stored values, no placeholder, and sends them as
26+
* they stand;
27+
* - (f) a managed row says why it has no Sync; an external row and the
28+
* primary datasource say nothing;
29+
* - (g) zh: the sentences read in Chinese.
30+
*/
31+
32+
import * as React from 'react';
33+
import { describe, it, expect, vi, afterEach } from 'vitest';
34+
import { render, screen, cleanup, fireEvent, within, waitFor } from '@testing-library/react';
35+
import userEvent from '@testing-library/user-event';
36+
import { I18nProvider } from '@object-ui/i18n';
37+
import { toast } from 'sonner';
38+
import { t } from '../i18n.js';
39+
40+
type Reply = { ok: boolean; status: number; text: () => Promise<string> };
41+
42+
const state = vi.hoisted(() => ({
43+
fetch: (async () => undefined) as unknown as (url: string, init?: RequestInit) => Promise<unknown>,
44+
// STABLE identity, like the real memoized client.
45+
metadataClient: {},
46+
}));
47+
48+
vi.mock('@object-ui/auth', async (importOriginal) => ({
49+
...(await importOriginal<Record<string, unknown>>()),
50+
createAuthenticatedFetch: () => (url: string, init?: RequestInit) => state.fetch(url, init),
51+
}));
52+
vi.mock('../useMetadata.js', () => ({
53+
useMetadataClient: () => state.metadataClient,
54+
}));
55+
vi.mock('sonner', () => ({
56+
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), message: vi.fn() },
57+
}));
58+
59+
import { DatasourceResourcePage } from './DatasourceResourcePage';
60+
61+
// The zh rows (g) reads live in the `studio` namespace's lazily fetched `zh`
62+
// catalogue; resident before any case, so they read synchronously.
63+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
64+
await loadStudioCatalogue('zh');
65+
66+
afterEach(() => {
67+
cleanup();
68+
vi.mocked(toast.success).mockClear();
69+
vi.mocked(toast.error).mockClear();
70+
});
71+
72+
const reply = (status: number, payload: unknown): Reply => ({
73+
ok: status < 400,
74+
status,
75+
text: async () => JSON.stringify(payload),
76+
});
77+
const ok = (data: unknown, status = 200) => reply(status, { success: true, data });
78+
79+
/** The catalog's SQLite entry as `GET /datasources/drivers` serves it: a defaulted text box and a defaulted switch. */
80+
const SQLITE = {
81+
id: 'sqlite',
82+
label: 'SQLite',
83+
configSchema: {
84+
properties: {
85+
filename: { type: 'string', title: 'Filename', default: ':memory:', description: 'Database file path, or ":memory:" for an ephemeral database' },
86+
wal: { type: 'boolean', title: 'WAL', default: true },
87+
},
88+
},
89+
};
90+
/** A defaulted number box beside a defaulted text box. */
91+
const POSTGRES = {
92+
id: 'postgres',
93+
label: 'PostgreSQL',
94+
configSchema: {
95+
properties: {
96+
host: { type: 'string', title: 'Host', default: 'localhost' },
97+
port: { type: 'integer', title: 'Port', default: 5432 },
98+
database: { type: 'string', title: 'Database' },
99+
},
100+
},
101+
};
102+
103+
interface Seen { method: string; url: string; body?: Record<string, unknown> }
104+
105+
function renderPage(opts: { rows?: unknown[]; stored?: Record<string, unknown>; language?: string } = {}) {
106+
const seen: Seen[] = [];
107+
state.fetch = (async (url: string, init?: RequestInit) => {
108+
const method = (init?.method ?? 'GET').toUpperCase();
109+
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as Record<string, unknown>) : undefined;
110+
seen.push({ method, url, body });
111+
if (url === '/api/v1/datasources/drivers') return ok({ drivers: [SQLITE, POSTGRES] });
112+
if (url === '/api/v1/datasources' && method === 'GET') return ok({ datasources: opts.rows ?? [] });
113+
if (url === '/api/v1/datasources' && method === 'POST') return ok({ datasource: { name: body?.name } }, 201);
114+
if (url === '/api/v1/datasources/test') return ok({ result: { ok: true } });
115+
if (opts.stored && url === `/api/v1/datasources/${String(opts.stored.name)}` && method === 'GET') {
116+
return ok({ datasource: opts.stored });
117+
}
118+
if (opts.stored && url === `/api/v1/datasources/${String(opts.stored.name)}` && method === 'PATCH') {
119+
return ok({ datasource: { name: opts.stored.name } });
120+
}
121+
return reply(404, { success: false, error: { code: 'NOT_FOUND', message: `unexpected ${method} ${url}` } });
122+
}) as typeof state.fetch;
123+
render(
124+
<I18nProvider config={{ defaultLanguage: opts.language ?? 'en', detectBrowserLanguage: false }}>
125+
<DatasourceResourcePage />
126+
</I18nProvider>,
127+
);
128+
return seen;
129+
}
130+
131+
/** The input or select trigger beside a form label (the page's labels carry no `htmlFor`). */
132+
function controlUnder(label: string | RegExp): HTMLElement {
133+
const lab = screen.getByText(label, { selector: 'label' });
134+
// A switch sits beside the div that holds its label; every other control under it.
135+
const control = lab.parentElement?.querySelector('input, [role="combobox"], [role="switch"]')
136+
?? lab.parentElement?.parentElement?.querySelector('[role="switch"]');
137+
expect(control, `no control beside the "${String(label)}" label`).toBeTruthy();
138+
return control as HTMLElement;
139+
}
140+
141+
async function openNew(language = 'en') {
142+
fireEvent.click(await screen.findByRole('button', { name: t('engine.datasource.new', language === 'zh' ? 'zh-CN' : 'en-US') }));
143+
return screen.findByRole('dialog');
144+
}
145+
146+
async function chooseDriver(label: string) {
147+
await userEvent.click(controlUnder(/^Driver/));
148+
await userEvent.click(await screen.findByRole('option', { name: label }));
149+
}
150+
151+
async function chooseMode(name: RegExp) {
152+
await userEvent.click(controlUnder('Schema mode'));
153+
await userEvent.click(await screen.findByRole('option', { name }));
154+
}
155+
156+
const lastWrite = (seen: Seen[], method: string, url: string) => {
157+
const hits = seen.filter((r) => r.method === method && r.url === url);
158+
expect(hits.length, `a ${method} ${url} was sent`).toBeGreaterThan(0);
159+
return hits[hits.length - 1].body ?? {};
160+
};
161+
162+
const EN = 'en-US';
163+
const ZH = 'zh-CN';
164+
165+
describe('New datasource — starts on External and says what each schema mode does (objectstack#30327)', () => {
166+
it('(a) starts on External with its sentence, and Create sends external with the block', async () => {
167+
const seen = renderPage();
168+
const dialog = await openNew();
169+
expect(within(controlUnder('Schema mode')).getByText(t('engine.datasource.schemaModeExternal', EN))).toBeTruthy();
170+
expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeExternalHint', EN));
171+
fireEvent.change(controlUnder('Name *'), { target: { value: 'parts_db' } });
172+
fireEvent.change(controlUnder('Filename'), { target: { value: '/srv/data/parts.db' } });
173+
fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ }));
174+
await waitFor(() => expect(toast.success).toHaveBeenCalled());
175+
const post = lastWrite(seen, 'POST', '/api/v1/datasources');
176+
expect(post).toMatchObject({ name: 'parts_db', driver: 'sqlite', schemaMode: 'external', config: { filename: '/srv/data/parts.db' } });
177+
expect(post.external).toEqual({});
178+
});
179+
180+
it('(b) Managed swaps the sentence, and Create sends managed with no block', async () => {
181+
const seen = renderPage();
182+
const dialog = await openNew();
183+
await chooseMode(/^Managed/);
184+
expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeManagedHint', EN));
185+
await chooseMode(/^Validate only/);
186+
expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeValidateOnlyHint', EN));
187+
await chooseMode(/^Managed/);
188+
fireEvent.change(controlUnder('Name *'), { target: { value: 'scratch' } });
189+
fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ }));
190+
await waitFor(() => expect(toast.success).toHaveBeenCalled());
191+
const post = lastWrite(seen, 'POST', '/api/v1/datasources');
192+
expect(post.schemaMode).toBe('managed');
193+
expect('external' in post).toBe(false);
194+
});
195+
});
196+
197+
describe('New datasource — a box default is a placeholder, not a value (objectstack#30331)', () => {
198+
it('(c) Filename starts empty with :memory: as its placeholder; a typed path is the whole value', async () => {
199+
const seen = renderPage();
200+
const dialog = await openNew();
201+
const filename = controlUnder('Filename') as HTMLInputElement;
202+
expect(filename.value).toBe('');
203+
expect(filename.placeholder).toBe(':memory:');
204+
await userEvent.type(filename, '/srv/data/parts.db');
205+
expect(filename.value).toBe('/srv/data/parts.db');
206+
fireEvent.click(within(dialog).getByRole('button', { name: /Test connection/ }));
207+
await waitFor(() => expect(seen.some((r) => r.url === '/api/v1/datasources/test')).toBe(true));
208+
expect(lastWrite(seen, 'POST', '/api/v1/datasources/test')).toMatchObject({ driver: 'sqlite', config: { filename: '/srv/data/parts.db', wal: true } });
209+
});
210+
211+
it('(d) CONTROL: boxes left empty send their defaults; a switch keeps its default as its value', async () => {
212+
const seen = renderPage();
213+
const dialog = await openNew();
214+
expect((controlUnder('WAL')).getAttribute('aria-checked')).toBe('true');
215+
fireEvent.click(within(dialog).getByRole('button', { name: /Test connection/ }));
216+
await waitFor(() => expect(seen.some((r) => r.url === '/api/v1/datasources/test')).toBe(true));
217+
expect(lastWrite(seen, 'POST', '/api/v1/datasources/test').config).toEqual({ filename: ':memory:', wal: true });
218+
219+
await chooseDriver('PostgreSQL');
220+
const host = controlUnder('Host') as HTMLInputElement;
221+
const port = controlUnder('Port') as HTMLInputElement;
222+
expect([host.value, host.placeholder]).toEqual(['', 'localhost']);
223+
expect([port.value, port.placeholder]).toEqual(['', '5432']);
224+
fireEvent.change(controlUnder('Name *'), { target: { value: 'warehouse' } });
225+
fireEvent.change(controlUnder('Database'), { target: { value: 'wh' } });
226+
// Typed then cleared counts as empty.
227+
fireEvent.change(host, { target: { value: 'db' } });
228+
fireEvent.change(host, { target: { value: '' } });
229+
fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ }));
230+
await waitFor(() => expect(toast.success).toHaveBeenCalled());
231+
expect(lastWrite(seen, 'POST', '/api/v1/datasources').config).toEqual({ host: 'localhost', port: 5432, database: 'wh' });
232+
});
233+
234+
it('(e) an edit shows the stored values with no placeholder, and sends them as they stand', async () => {
235+
const stored = { name: 'parts_db', driver: 'sqlite', schemaMode: 'external', config: { filename: '/srv/data/parts.db' }, origin: 'runtime', hasSecret: false };
236+
const seen = renderPage({ rows: [{ name: 'parts_db', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'external' }], stored });
237+
fireEvent.click(await screen.findByTitle(t('engine.datasource.edit', EN)));
238+
const dialog = await screen.findByRole('dialog');
239+
const filename = controlUnder('Filename') as HTMLInputElement;
240+
expect(filename.value).toBe('/srv/data/parts.db');
241+
expect(filename.placeholder).toBe('');
242+
fireEvent.click(within(dialog).getByRole('button', { name: /Save changes/ }));
243+
await waitFor(() => expect(toast.success).toHaveBeenCalled());
244+
expect(lastWrite(seen, 'PATCH', '/api/v1/datasources/parts_db').config).toEqual({ filename: '/srv/data/parts.db' });
245+
});
246+
});
247+
248+
describe('A managed row says why it has no Sync (objectstack#30327)', () => {
249+
const rows = [
250+
{ name: 'default', driver: 'sqlite', origin: 'code', status: 'ok', schemaMode: 'managed' },
251+
{ name: 'scratch', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'managed' },
252+
{ name: 'parts_db', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'external' },
253+
];
254+
const rowOf = (name: string) => {
255+
const row = screen.getByText(name, { selector: 'code' }).closest('div.flex.items-center.gap-3');
256+
if (!(row instanceof HTMLElement)) throw new Error(`no ${name} row`);
257+
return row;
258+
};
259+
260+
it('(f) the managed row carries the note; the external row and the primary datasource do not', async () => {
261+
renderPage({ rows });
262+
await screen.findByText('scratch', { selector: 'code' });
263+
expect(within(rowOf('scratch')).getByTestId('datasource-no-sync-note').textContent).toBe(t('engine.datasource.noSyncManaged', EN));
264+
expect(within(rowOf('scratch')).queryByRole('button', { name: t('engine.datasource.sync', EN) })).toBeNull();
265+
expect(within(rowOf('parts_db')).queryByTestId('datasource-no-sync-note')).toBeNull();
266+
expect(within(rowOf('parts_db')).getByRole('button', { name: t('engine.datasource.sync', EN) })).toBeTruthy();
267+
expect(within(rowOf('default')).queryByTestId('datasource-no-sync-note')).toBeNull();
268+
});
269+
270+
it('(g) zh: the mode sentences and the note read in Chinese', async () => {
271+
for (const key of [
272+
'engine.datasource.schemaModeExternal',
273+
'engine.datasource.schemaModeManaged',
274+
'engine.datasource.schemaModeExternalHint',
275+
'engine.datasource.schemaModeManagedHint',
276+
'engine.datasource.schemaModeValidateOnlyHint',
277+
'engine.datasource.noSyncManaged',
278+
]) {
279+
expect(t(key, ZH), key).toMatch(/\p{Script=Han}/u);
280+
expect(t(key, ZH), key).not.toBe(t(key, EN));
281+
}
282+
renderPage({ rows, language: 'zh' });
283+
await screen.findByText('scratch', { selector: 'code' });
284+
expect(within(rowOf('scratch')).getByTestId('datasource-no-sync-note').textContent).toBe(t('engine.datasource.noSyncManaged', ZH));
285+
const dialog = await openNew('zh');
286+
expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeExternalHint', ZH));
287+
});
288+
});

0 commit comments

Comments
 (0)