Skip to content

Commit ab12a3b

Browse files
fix(app-shell): the datasource delete confirm and its refusal name the objects that still use it, in the console's language (#30442)
domain:ui#5 · os-sales · session_01L6bhgW7fTeU6tAZXwiAJnW <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuQqREozq6kAm9qNDvozztaj)_ Refs #30335 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:设置 → 数据源,删除确认框会列出仍在使用该数据源的对象,并说明在它们还在时删不掉;服务端拒绝删除后,提示改用控制台语言,点名这些对象并说明下一步(删除对象或改到其他数据源)。 - 为什么改:服务端拒绝时只回一句英文和一个数量,页面原样塞进中文外框(「删除 parts_db 失败:Cannot remove datasource …」),中文管理员看不懂,也不知道是哪些对象挡住了。 - 风险与回滚:只改前端一个页面和 studio 词条(新增 3 行 en/zh);对象读取失败或非 400 失败时保持原样显示服务端原文;回滚即还原本 PR,无数据迁移。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx`(`openDeleteConfirm`、`remove` 的 400 分支)、`DatasourceResourcePage.deleteConfirm-13333.test.tsx` 第二个 describe、`ui/packages/i18n/src/namespaces/studio/zh.ts` 的 `engine.datasource.deleteBound*`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.deleteConfirm-13333.test.tsx`,预期 8 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Card #30335, the **datasource half only**. The storage half (`SettingsView.tsx`, console locales) stays on the card, serial behind #30329, so this PR does not close it. - **The confirm.** Opening a datasource's delete reads object metadata (`MetadataClient.list('object')`) and keeps each object whose `datasource` names the row, the same test the server's delete guard counts by. While any is listed, the dialog body says the datasource cannot be deleted yet and asks to delete each object or move it to another datasource first, and the objects are listed by label with the name beside it. With none, or when the read fails, the dialog is unchanged. The delete button stays enabled: the server is still the judge. - **The refusal.** `api()` now keeps the HTTP status and the envelope's `error.code` on the thrown error. A `400` from `DELETE /api/v1/datasources/:name` re-reads the bound objects; when any remain, the toast is `engine.datasource.deleteBoundRefused`, naming the datasource, the objects (joined with the locale's separator row) and the next step. Every other failure, and a `400` with no bound object, keeps the server's message in the existing `engine.datasource.deleteFailed` frame. No server text is matched. - **i18n (the widening).** Three new `studio` namespace rows, en and zh: `engine.datasource.deleteBoundBody`, `engine.datasource.deleteBoundRefused`, `engine.datasource.boundObjectSeparator` (a punctuation row, since the package's `lib` is ES2020 and has no `Intl.ListFormat`). - **Changeset.** `.changeset/30335-datasource-delete-names-bound-objects.md`, `@object-ui/app-shell` and `@object-ui/i18n` patch. Branch: the harness-assigned `claude/project-thread-x1xjpw`, not `claude/issue-30335-*`; the card is #30335. ## Tests `DatasourceResourcePage.deleteConfirm-13333.test.tsx` gains a second top-level describe (5 cases) on the existing harness; expectations are read from Studio's table, never retyped: - guard: the two new zh rows are real zh rows; - the confirm lists the three objects bound to `parts_db` (label with name, name alone when unlabelled) and not the one on `default`, with the bound body instead of the plain one; - a `400` refusal is reported in zh, naming the objects, and the server's English never reaches the toast; - CONTROL: no bound object keeps the plain body, and a `400` keeps the server's message; - CONTROL: a `503` keeps the server's message even while objects are bound. The three existing cases run unchanged (their fake metadata client has no `list`, which exercises the failed-read path). ## Acceptance notes - Row Test failure toast (the `test` handler's `${name}: ${message}`) is untouched: it does not fall out of this change. - Server half, out of scope here (`packages/**` is the server lane): the refusal is a plain `Error` answered `400 DATASOURCE_ADMIN_ERROR` with a count and no object names or `details`, so a client that does not read object metadata cannot name them. Reported to the seat to file. - The confirm's list is a metadata read at open time; the server's count is taken at delete time from the same object listing. They can differ only if an object is saved in between, and then the server still refuses and the toast re-reads. ## Verification Gates and tests ran on `de1864eb`; the head adds an evidence commit and its removal, the tree is byte-identical (`git diff de1864e HEAD` is empty). Base `fdd06415`. <details><summary>Live rig and screenshots (zh-CN, 1440 wide)</summary> `examples/app-showcase` `objectstack dev --seed-admin` on a private port, the console via `vite` in `ui/apps/console` with `DEV_PROXY_TARGET`, Chromium, browser locale zh-CN. A runtime sqlite datasource `parts_db` (label 零件库) with two objects saved through `PUT /api/v1/meta/object/:name` naming it (零件 `parts_part`, 供应商 `parts_supplier`). `DELETE /api/v1/datasources/parts_db` answered `HTTP 400` with `{"success":false,"error":{"code":"DATASOURCE_ADMIN_ERROR","message":"Cannot remove datasource 'parts_db': 2 object(s) are still bound to it."}}`, and `GET /api/v1/meta/object` served both objects with `datasource: parts_db`. Before (page restored to the base for the shot): confirm `连接 parts_db 将被移除。此操作不可撤销。`; toast `删除 parts_db 失败:Cannot remove datasource 'parts_db': 2 object(s) are still bound to it.` ![before confirm](https://github.com/objectstack-ai/objectstack/blob/e3cffec4e8337f6d5e3975fca00b0e7d1c2b2b5f/evidence/30335/before-1-confirm-zh-1440.png?raw=true) ![before toast](https://github.com/objectstack-ai/objectstack/blob/e3cffec4e8337f6d5e3975fca00b0e7d1c2b2b5f/evidence/30335/before-2-refused-toast-zh-1440.png?raw=true) After: confirm `以下对象仍在使用 parts_db,暂时无法删除。请先删除这些对象,或把它们改到其他数据源:` with `零件 (parts_part)`, `供应商 (parts_supplier)`; toast `未删除 parts_db:零件 (parts_part)、供应商 (parts_supplier) 仍在使用它。请先删除这些对象或把它们改到其他数据源,再删除 parts_db。` ![after confirm](https://github.com/objectstack-ai/objectstack/blob/e3cffec4e8337f6d5e3975fca00b0e7d1c2b2b5f/evidence/30335/after-1-confirm-zh-1440.png?raw=true) ![after toast](https://github.com/objectstack-ai/objectstack/blob/e3cffec4e8337f6d5e3975fca00b0e7d1c2b2b5f/evidence/30335/after-2-refused-toast-zh-1440.png?raw=true) </details> <details><summary>Tests, ablation, typecheck, lint</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/datasource/ packages/i18n/src/__tests__/studio-namespace.test.ts`: Test Files 8 passed (8), Tests 61 passed (61), lock VERDICT command-exit 0. - Ablation 1 (`scripts/ablation-replace.mjs`, anchor x1 to x0): the refusal's re-read replaced by an empty list: 1 failed / 7 passed (the zh refusal case). Restored, blob equals HEAD. - Ablation 2: `confirmBound` forced empty: 3 failed / 5 passed (the confirm list case and the two cases that wait on it). Restored, blob equals HEAD. - `pnpm --filter @object-ui/i18n --filter @object-ui/app-shell type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`): lock VERDICT command-exit 0. - eslint on the four changed source files: 0 errors, 2 warnings, both on lines this PR does not change. - `pnpm check:new-line-citations` (ui): 0 new citations, exit 0. </details> <details><summary>Derived gates (35/35 exit 0)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 families at `de1864eb`; every one ran and exited 0. `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED (a derived zero, every line carries an exit code). `pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped; 4985 tests passed, 2 skipped. CI runs the rest. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgW7fTeU6tAZXwiAJnW)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgW7fTeU6tAZXwiAJnW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7b8876 commit ab12a3b

5 files changed

Lines changed: 203 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): Setup → Datasources names the objects that still use a datasource, in the console's language, before and after its delete
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **The confirm.** Opening a datasource's delete reads object metadata and lists every object whose `datasource` names it (label, with the name beside it when they differ). While any is listed, the confirm says the datasource cannot be deleted yet and that each object must be deleted or moved to another datasource first. With none, or when the read fails, the confirm is unchanged.
12+
- **The refusal.** A `400` from `DELETE /api/v1/datasources/:name` re-reads that list. When objects still name the datasource, the toast says, in the console's language, that it was not deleted, which objects still use it, and what to do next. Any other failure, and a `400` with no bound object, keep the server's message in the existing frame. No server text is matched.
13+
- **The widening.** Three new `studio` namespace rows in `@object-ui/i18n` (en and zh): `engine.datasource.deleteBoundBody`, `engine.datasource.deleteBoundRefused` and `engine.datasource.boundObjectSeparator`.

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
* - (b) CONTROL: Cancel sends no `DELETE`.
1212
* - (c) a row with no label is named by its name.
1313
* - every case: `window.confirm` is never called.
14+
*
15+
* The objects that still use the datasource (the second describe): the confirm
16+
* lists the objects whose metadata names it, and a `400` refusal is reported in
17+
* the console's language, naming those objects and what to do. Expectations are
18+
* read from Studio's table, never retyped; no case matches the server's English.
1419
*/
1520

1621
import * as React from 'react';
@@ -36,8 +41,15 @@ vi.mock('sonner', () => ({
3641
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), message: vi.fn() },
3742
}));
3843

44+
import { toast } from 'sonner';
45+
import { t, tFormat } from '../i18n';
3946
import { DatasourceResourcePage } from './DatasourceResourcePage';
4047

48+
// The zh rows read below live in the lazily fetched `studio` namespace; resident
49+
// before any case, so they read synchronously.
50+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
51+
await loadStudioCatalogue('zh');
52+
4153
const nativeConfirm = vi.fn(() => true);
4254
beforeEach(() => {
4355
nativeConfirm.mockClear();
@@ -104,3 +116,107 @@ describe('DatasourceResourcePage — the delete confirm is in-app and names the
104116
expect(within(dialog).getByRole('heading').textContent).toBe('Delete datasource "warehouse"?');
105117
});
106118
});
119+
120+
const OBJECTS = [
121+
{ name: 'part', label: 'Part', datasource: 'parts_db' },
122+
{ name: 'supplier', label: '供应商', datasource: 'parts_db' },
123+
{ name: 'stock_level', datasource: 'parts_db' },
124+
{ name: 'account', label: 'Account', datasource: 'default' },
125+
];
126+
/** The server's own refusal text: what the page shows when it cannot name the objects. */
127+
const SERVER_REFUSAL = "Cannot remove datasource 'parts_db': 3 object(s) are still bound to it.";
128+
129+
function renderBound(
130+
language: string,
131+
{ objects = OBJECTS, deleteStatus = 400 }: { objects?: unknown[]; deleteStatus?: number } = {},
132+
) {
133+
const deletes: string[] = [];
134+
state.metadataClient = { list: async (type: string) => (type === 'object' ? objects : []) };
135+
state.fetch = (async (url: string, init?: RequestInit) => {
136+
const method = init?.method ?? 'GET';
137+
if (url === '/api/v1/datasources' && method === 'GET') {
138+
return ok({ datasources: [{ ...BASE, name: 'parts_db', label: 'Parts DB' }] });
139+
}
140+
if (method === 'DELETE') {
141+
deletes.push(url);
142+
return reply(deleteStatus, { success: false, error: { code: 'DATASOURCE_ADMIN_ERROR', message: SERVER_REFUSAL } });
143+
}
144+
return reply(404, { success: false, error: { message: `unexpected ${method} ${url}` } });
145+
}) as typeof state.fetch;
146+
render(
147+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
148+
<DatasourceResourcePage />
149+
</I18nProvider>,
150+
);
151+
return deletes;
152+
}
153+
154+
async function openConfirm(deleteTitle: string) {
155+
fireEvent.click(await screen.findByTitle(deleteTitle));
156+
return screen.findByRole('alertdialog');
157+
}
158+
159+
describe('DatasourceResourcePage — the objects that still use a datasource are named before and after its delete', () => {
160+
afterEach(() => {
161+
state.metadataClient = {};
162+
vi.mocked(toast.error).mockClear();
163+
});
164+
165+
it('guard: the zh rows the cases read are real zh rows', () => {
166+
for (const key of ['engine.datasource.deleteBoundBody', 'engine.datasource.deleteBoundRefused']) {
167+
expect(t(key, 'zh-CN')).not.toBe(t(key, 'en-US'));
168+
expect(t(key, 'zh-CN')).toMatch(/\p{Script=Han}/u);
169+
}
170+
});
171+
172+
it('the confirm lists the objects bound to this datasource, and no other datasource\'s', async () => {
173+
renderBound('zh');
174+
const dialog = await openConfirm(t('engine.datasource.delete', 'zh-CN'));
175+
const list = await within(dialog).findByTestId('datasource-delete-bound');
176+
expect(within(list).getAllByRole('listitem').map((li) => li.textContent)).toEqual([
177+
'Part (part)',
178+
'供应商 (supplier)',
179+
'stock_level',
180+
]);
181+
expect(dialog.textContent).toContain(tFormat('engine.datasource.deleteBoundBody', 'zh-CN', { name: 'parts_db' }));
182+
expect(dialog.textContent).not.toContain(tFormat('engine.datasource.deleteBody', 'zh-CN', { name: 'parts_db' }));
183+
});
184+
185+
it('a 400 refusal is reported in zh, naming the objects, never in the server\'s English', async () => {
186+
const deletes = renderBound('zh');
187+
const dialog = await openConfirm(t('engine.datasource.delete', 'zh-CN'));
188+
await within(dialog).findByTestId('datasource-delete-bound');
189+
fireEvent.click(within(dialog).getByTestId('datasource-delete-confirm'));
190+
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
191+
expect(deletes).toEqual(['/api/v1/datasources/parts_db']);
192+
expect(vi.mocked(toast.error).mock.calls[0][0]).toBe(
193+
tFormat('engine.datasource.deleteBoundRefused', 'zh-CN', {
194+
name: 'parts_db',
195+
objects: ['Part (part)', '供应商 (supplier)', 'stock_level'].join(t('engine.datasource.boundObjectSeparator', 'zh-CN')),
196+
}),
197+
);
198+
});
199+
200+
it('CONTROL: with no bound object the confirm keeps its plain body, and a 400 keeps the server\'s message', async () => {
201+
renderBound('en', { objects: [{ name: 'account', datasource: 'default' }] });
202+
const dialog = await openConfirm('Delete');
203+
expect(dialog.textContent).toContain(tFormat('engine.datasource.deleteBody', 'en-US', { name: 'parts_db' }));
204+
expect(within(dialog).queryByTestId('datasource-delete-bound')).toBeNull();
205+
fireEvent.click(within(dialog).getByTestId('datasource-delete-confirm'));
206+
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
207+
expect(vi.mocked(toast.error).mock.calls[0][0]).toBe(
208+
tFormat('engine.datasource.deleteFailed', 'en-US', { name: 'parts_db', error: SERVER_REFUSAL }),
209+
);
210+
});
211+
212+
it('CONTROL: a failure that is not a 400 keeps the server\'s message even while objects are bound', async () => {
213+
renderBound('en', { deleteStatus: 503 });
214+
const dialog = await openConfirm('Delete');
215+
await within(dialog).findByTestId('datasource-delete-bound');
216+
fireEvent.click(within(dialog).getByTestId('datasource-delete-confirm'));
217+
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
218+
expect(vi.mocked(toast.error).mock.calls[0][0]).toBe(
219+
tFormat('engine.datasource.deleteFailed', 'en-US', { name: 'parts_db', error: SERVER_REFUSAL }),
220+
);
221+
});
222+
});

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

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
* Offered only on a federation source (`offersSync`, objectui#13318).
3535
* - **Delete** a runtime datasource (`DELETE …/:name`; blocked by the backend
3636
* while objects are still bound), confirmed in an in-app AlertDialog that
37-
* names the datasource by its label and name (objectui#13333).
37+
* names the datasource by its label and name (objectui#13333). The confirm
38+
* lists the objects whose metadata names the datasource, and a refused
39+
* delete names them in the console's language, with what to do next.
3840
*
3941
* Code-defined (`origin: 'code'`) datasources are read-only: edit/delete are
4042
* hidden for them.
@@ -125,6 +127,15 @@ interface DatasourceRow {
125127
active?: boolean;
126128
}
127129

130+
/** An object whose metadata names this datasource as its `datasource`. */
131+
interface BoundObject {
132+
name: string;
133+
label?: string;
134+
}
135+
136+
/** A refused request: the envelope's message, with the HTTP status and the envelope's `code` beside it. */
137+
type DatasourceRequestError = Error & { status?: number; code?: string };
138+
128139
/**
129140
* Status chip per verdict. `unvalidated` stays visually quiet — it means
130141
* "nothing is known", not "something is wrong" — while `error`/`blocked` carry
@@ -251,6 +262,11 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
251262
const [busy, setBusy] = React.useState<string | null>(null);
252263
// objectui#13333 — the row whose delete is being confirmed (in-app dialog).
253264
const [confirmDelete, setConfirmDelete] = React.useState<DatasourceRow | null>(null);
265+
/**
266+
* The objects bound to the datasource whose delete is being confirmed, keyed
267+
* by its name so an answer for an earlier row is never drawn under a later one.
268+
*/
269+
const [bound, setBound] = React.useState<{ datasource: string; objects: BoundObject[] } | null>(null);
254270

255271
/**
256272
* Fetch + unwrap the declared response envelope.
@@ -283,7 +299,8 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
283299
(body && typeof body.error === 'string' ? body.error : undefined) ||
284300
(body && body.message) ||
285301
`HTTP ${res.status}`;
286-
throw new Error(detail);
302+
const code = body && typeof body.error === 'object' && typeof body.error?.code === 'string' ? body.error.code : undefined;
303+
throw Object.assign(new Error(detail), { status: res.status, code }) as DatasourceRequestError;
287304
}
288305
return body && typeof body.success === 'boolean' && 'data' in body ? body.data : body;
289306
},
@@ -322,14 +339,47 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
322339
}
323340
};
324341

342+
/**
343+
* The objects whose metadata names `datasource` as theirs: the same test the
344+
* server's delete guard counts by (`object.datasource === name`).
345+
*/
346+
const readBoundObjects = async (datasource: string): Promise<BoundObject[]> => {
347+
const objects = await metaClient.list<{ name?: unknown; label?: unknown; datasource?: unknown }>('object');
348+
return objects
349+
.filter((o) => o?.datasource === datasource && typeof o.name === 'string')
350+
.map((o) => ({ name: o.name as string, ...(typeof o.label === 'string' && o.label ? { label: o.label } : {}) }));
351+
};
352+
353+
/** Each object by its label, with its name beside it when the two differ. */
354+
const boundObjectText = (o: BoundObject) => (o.label && o.label !== o.name ? `${o.label} (${o.name})` : o.name);
355+
356+
const openDeleteConfirm = (row: DatasourceRow) => {
357+
setConfirmDelete(row);
358+
setBound(null);
359+
// A failed read leaves the confirm as it was: the server still judges the delete.
360+
readBoundObjects(row.name).then(
361+
(objects) => setBound({ datasource: row.name, objects }),
362+
() => undefined,
363+
);
364+
};
365+
325366
const remove = async (name: string) => {
326367
setBusy(`del:${name}`);
327368
try {
328369
await api(`/api/v1/datasources/${encodeURIComponent(name)}`, { method: 'DELETE' });
329370
toast.success(tFormat('engine.datasource.deleted', locale, { name }));
330371
void load();
331372
} catch (err) {
332-
toast.error(tFormat('engine.datasource.deleteFailed', locale, { name, error: (err as Error).message }));
373+
// A 400 on a datasource that objects still name is the server's
374+
// bound-object refusal: read the objects now and name them in the
375+
// console's language. Any other failure keeps the server's own message.
376+
const objects = (err as DatasourceRequestError).status === 400 ? await readBoundObjects(name).catch(() => []) : [];
377+
if (objects.length) {
378+
const list = objects.map(boundObjectText).join(t('engine.datasource.boundObjectSeparator', locale));
379+
toast.error(tFormat('engine.datasource.deleteBoundRefused', locale, { name, objects: list }));
380+
} else {
381+
toast.error(tFormat('engine.datasource.deleteFailed', locale, { name, error: (err as Error).message }));
382+
}
333383
} finally {
334384
setBusy(null);
335385
}
@@ -619,6 +669,8 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
619669
);
620670
};
621671

672+
const confirmBound = confirmDelete && bound?.datasource === confirmDelete.name ? bound.objects : [];
673+
622674
return (
623675
<div className="mx-auto max-w-4xl px-6 py-8">
624676
<div className="mb-6 flex items-center justify-between">
@@ -686,7 +738,7 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
686738
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('engine.datasource.edit', locale)} disabled={busy === `edit:${ds.name}`} onClick={() => void openEdit(ds)}>
687739
{busy === `edit:${ds.name}` ? <Loader2 className="h-4 w-4 animate-spin" /> : <Pencil className="h-4 w-4" />}
688740
</Button>
689-
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" title={t('engine.datasource.delete', locale)} disabled={busy === `del:${ds.name}`} onClick={() => setConfirmDelete(ds)}>
741+
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" title={t('engine.datasource.delete', locale)} disabled={busy === `del:${ds.name}`} onClick={() => openDeleteConfirm(ds)}>
690742
{busy === `del:${ds.name}` ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
691743
</Button>
692744
</>
@@ -823,17 +875,25 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
823875
</DialogContent>
824876
</Dialog>
825877

826-
{/* Delete a datasource — names it by label and name (objectui#13333). */}
878+
{/* Delete a datasource — names it by label and name (objectui#13333), and
879+
lists the objects that still use it, which the server refuses the delete for. */}
827880
<AlertDialog open={confirmDelete !== null} onOpenChange={(o) => { if (!o) setConfirmDelete(null); }}>
828881
<AlertDialogContent data-testid="datasource-delete-dialog">
829882
<AlertDialogHeader>
830883
<AlertDialogTitle>
831884
{confirmDelete && tFormat('engine.datasource.deleteTitle', locale, { item: confirmDelete.label || confirmDelete.name })}
832885
</AlertDialogTitle>
833886
<AlertDialogDescription>
834-
{confirmDelete && tFormat('engine.datasource.deleteBody', locale, { name: confirmDelete.name })}
887+
{confirmDelete && (confirmBound.length
888+
? tFormat('engine.datasource.deleteBoundBody', locale, { name: confirmDelete.name })
889+
: tFormat('engine.datasource.deleteBody', locale, { name: confirmDelete.name }))}
835890
</AlertDialogDescription>
836891
</AlertDialogHeader>
892+
{confirmBound.length > 0 && (
893+
<ul data-testid="datasource-delete-bound" className="max-h-48 list-disc space-y-0.5 overflow-y-auto pl-5 text-sm">
894+
{confirmBound.map((o) => <li key={o.name}>{boundObjectText(o)}</li>)}
895+
</ul>
896+
)}
837897
<AlertDialogFooter>
838898
<AlertDialogCancel data-testid="datasource-delete-cancel">{t('engine.cancel', locale)}</AlertDialogCancel>
839899
<AlertDialogAction

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,6 +1980,11 @@ const STUDIO_EN: Record<string, string> = {
19801980
'engine.datasource.deleteTitle': 'Delete datasource "{item}"?',
19811981
'engine.datasource.deleteBody': 'The connection {name} is removed. This cannot be undone.',
19821982
'engine.datasource.delete': 'Delete',
1983+
// The objects bound to a datasource, read from object metadata (each object's
1984+
// `datasource`): the delete confirm lists them, and a refused delete names them.
1985+
'engine.datasource.deleteBoundBody': 'These objects still use {name}, so it cannot be deleted yet. Delete each object or move it to another datasource first:',
1986+
'engine.datasource.deleteBoundRefused': '{name} was not deleted: {objects} still use it. Delete these objects or move them to another datasource, then delete {name} again.',
1987+
'engine.datasource.boundObjectSeparator': ', ',
19831988
// objectui#13460 — the datasource page's own copy. Author data (names, driver
19841989
// labels and descriptions, server messages) and the driver config schema's enum
19851990
// option values (objectui#13353) are not rows here.

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1791,6 +1791,9 @@ const STUDIO_ZH: Record<string, string> = {
17911791
'engine.datasource.deleteTitle': '删除数据源“{item}”?',
17921792
'engine.datasource.deleteBody': '连接 {name} 将被移除。此操作不可撤销。',
17931793
'engine.datasource.delete': '删除',
1794+
'engine.datasource.deleteBoundBody': '以下对象仍在使用 {name},暂时无法删除。请先删除这些对象,或把它们改到其他数据源:',
1795+
'engine.datasource.deleteBoundRefused': '未删除 {name}:{objects} 仍在使用它。请先删除这些对象或把它们改到其他数据源,再删除 {name}。',
1796+
'engine.datasource.boundObjectSeparator': '、',
17941797
'engine.datasource.title': '数据源',
17951798
'engine.datasource.subtitle': '连接外部数据库,并将其中的表同步为对象。',
17961799
'engine.datasource.refresh': '刷新',

0 commit comments

Comments
 (0)