Skip to content

Commit 66b17a3

Browse files
feat(studio): the Delete package dialog states what goes (objects with row counts, items by type, permanent drop) (#31193)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XEaDWcgFZCJ157i8WCtYTxk)_ Part of #31025 — this lands Expected 1 (the Delete package dialog states what goes). Expected 2, the `packageRemoval` chat confirm card, stays on the card: its payload is produced only in the cloud repository, which this lane cannot read, and the seat ruled the shape is not to be guessed. Clause-②: yes (widening) — measured on this diff: the published `studio` catalogue of `@object-ui/i18n` gains ten keys and changes or removes none. Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 的「删除软件包」确认框现在列出会删掉什么:每个对象及其记录数、其他元数据按类型计数、未发布草稿数;选择「删除结构和数据」时多一行红色提示「表会被永久删除,不进回收站,无法恢复」。 - 为什么改:原来确认框只让你输名字、选是否保留数据,不说删哪些对象、各有多少行,也没说删表是永久的、进不了回收站。 - 风险与回滚:只读,纯界面:打开确认框时多发一次 `GET /api/v1/packages/:id/export` 和每个对象一次 `$top=0` 的数据计数;读失败只显示一行说明,删除照常可用。回滚即 revert。 - 先看:`PackagesPage.tsx` 里打开确认框时读取的那个 effect;`packageRemovalContents.ts` 如何把导出结果折成按类型计数;`PackageRemovalSummary.tsx` 永久删除提示的显示条件。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx`,预期 `Tests 20 passed (20)`。 - 需要你做的:无,席位落地。 ## What changes - **`ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`**: pressing Delete package marks the list loading and opens the dialog; while the dialog is open, an effect keyed on the package id reads `GET /api/v1/packages/:id/export` (the page's existing `apiJson`), then counts each object's rows through the data source the Console already uses for record counts (`useAdapter().find(name, { $top: 0 })`, reading `total`, as `objectViewLiveData.ts` and `DraftChangesPanel.tsx` do). The adapter is read through a ref, so the effect keys on the id alone (ui `AGENTS.md` #10). Closing the dialog cancels the reads in flight. - **`packageRemovalContents.ts`** (new): reads the export answer into what the dialog lists. Each collection key is folded to its singular type with `canonicalMetaUrlType` (`@objectstack/spec/shared`); `viewItems` (`ASSEMBLED_VIEW_ITEMS_KEY`, `@objectstack/spec/ui`) counts as views; `withheldMaps`, the export's one declared list that is not a collection, is skipped. Objects keep their name and label for the count rows. - **`PackageRemovalSummary.tsx`** (new): the list (each object with its count, then each other type with its count), the pending-drafts line from the drafts the sheet already reads, and the permanent-drop warning. The warning shows only when the author chose to drop the data, and not when the package is known to hold no object. A count the data list does not answer reads "Record count unavailable", never `0`. An export the server refuses reads "Could not read what the package holds. Deleting it still removes everything in it.", and the delete stays available. - **`ui/packages/i18n/src/namespaces/studio/{en,zh}.ts`**: ten `engine.packages.detail.delete*` strings in both catalogues. - **`.changeset/31025-delete-package-contents.md`**: `@object-ui/i18n` patch (the namespace ships in its `dist`) with both declaration lines. `@object-ui/app-shell` is private. ## Tests Five cases in a new top-level `describe` of the shared delete harness, `PackagesPage.confirmFlows-11784.test.tsx`, driving the real `PackageDetailSheet` through clicks (Delete package, the mode radios, the name input, Confirm): - lists each object with its count (`1234 records`, `1 record`) and the other items by type (views folded from `views` + `viewItems`, dashboards, flows, object extensions); `withheldMaps` is not listed; the drafts line; each count is `find(name, { $top: 0 })`; opening the list writes nothing; - the permanent line is absent with no mode and with *structure only*, present after *structure and data*, and gone again on switching back; - a package whose export holds no object draws no permanent line under *structure and data*; - a missing `total` and a rejected count both read "Record count unavailable", not `0`; - an export answering 500 says so, keeps the permanent line under *structure and data*, and the DELETE is still sent (no query). The harness gains a module-top `import '@objectstack/formula'` and `import '@objectstack/lint'` (`check:test-source-alias` asks it of an edited file whose source reaches them through a dynamic import), a `vi.mock` of `@object-ui/react` whose `useAdapter` answers `null` unless a case installs a data source, and an export answer the stub serves. Ablations (`scripts/ablation-replace.mjs`, each anchor hit once, each restored to the HEAD blob with `git diff HEAD` empty): | mutation | red | |:--|:--| | permanent line ignores the chosen mode | the mode case, 1 failed / 19 passed | | permanent line ignores "no object" | the no-object case, 1 failed / 19 passed | | a missing `total` counted as `0` | the unknown-count case, 1 failed / 19 passed | | `withheldMaps` not skipped | the listing case, 1 failed / 19 passed | The fourth ablation's first attempt was refused by the tool (its replacement text already occurred in the file) and restored; it was re-run with a unique replacement. ## Acceptance notes - **What the list does not show.** The export door skips a package's datasources and email templates (`assemblePackageManifest`: "not consumed by registerApp"), while the delete removes them too, so those two types are not counted in the dialog. The dialog's description line above the list is unchanged. Not filed; named here for the seat. - **Whose count.** `total` is the rows the caller can read, the figure the object list's footer shows. Dropping a table removes every row in it, which can be more than the caller sees under row-level sharing. - **Read gate.** The export door asks `studio.access` or `setup.access`; the delete door asks `manage_metadata`. A caller who holds only the second sees the "could not read" line and can still delete. - **No new route** (seat ruling): both reads already exist, and neither door changes. - **The chat card** for `packageRemoval` is not in this PR (seat ruling): the payload is produced by cloud's `delete_package`, the cloud repository is out of this lane's reach, and nothing on `main` spells it. ## Verification All local readings below are at head `d869c685` unless a line names another (base `8ef4d309`, `origin/main` when the branch was cut). The last commit only changed the changeset's `Clause-②:` arm; the families that read a changeset were re-run on it. <details><summary>Tests, type-check and lint</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx packages/i18n/src/__tests__/studio-namespace.test.ts` (under the verify lock, at `7dcc7b0d`, whose source equals this head's): Test Files 2 passed (2), Tests 38 passed (38). - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/ packages/i18n/src/__tests__/studio-namespace.test.ts` (at `c8a24f8c`, before the harness's two module-top imports): Test Files 731 passed (731), Tests 7780 passed, 1 skipped. - `pnpm --filter @object-ui/app-shell run type-check` (`tsc --noEmit` and `tsc -p tsconfig.test.json`, which compiles the harness): exit 0, after `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` (VERDICT command-exit 0). - `pnpm exec eslint` from `ui/` over the three app-shell source files: exit 0, 0 errors, 15 warnings, the same 15 that `PackagesPage.tsx` on `origin/main` carries alone (the new files add none). - `pnpm --dir ui run check:new-line-citations`: 0 new citations. `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`: exit 0 each; the dead-key report names none of the new keys. </details> <details><summary>Derived gates (dispatch-gates --commands, 36 families)</summary> - All 36 run, exit codes captured before any pipe, all exit 0. `dispatch-gates.mjs --ran`: "36 derived famil(ies) accounted for — 36 run, 0 NOT-MEASURED". - The loop ran on `7dcc7b0d`; on `d869c685` the changeset readers were re-run (`check-adr-0087-registration --base origin/main`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:nul-bytes`, `check-closing-keyword-parity`, `check:published-files`), all exit 0. - First reading of `pnpm check:test-source-alias` was exit 1: the edited harness reaches `@objectstack/formula` and `@objectstack/lint` through the page's dynamic imports. Fixed by the two module-top imports; exit 0 after. </details> Not measured locally: a live Console check against a booted server (the export answer in the pins is shaped from `PackageExportManifestSchema`), and the CI-owned families (`Test UI`, `Lint UI`, `Type Check UI` and the rest), read from this PR's check runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kYWTJtoyM3pr9E5wF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c31821 commit 66b17a3

7 files changed

Lines changed: 381 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
feat(i18n): Studio strings for what the Delete package dialog removes
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The `studio` namespace gains the `engine.packages.detail.delete*` strings (en and zh) the Delete package dialog now draws: its heading for what the package holds, each object's record count (one, other, unavailable), the pending-drafts line, and the warning shown when the author chooses to drop the data, that the tables are dropped permanently and do not go to the recycle bin.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Delete package dialog's list of what goes (objectstack#31025), drawn from
5+
* a {@link PackageRemovalState}; `packageRemovalContents.ts` says where each
6+
* figure is read from.
7+
*/
8+
9+
import * as React from 'react';
10+
import { t, tFormat, translateMetadataType, type SupportedLocale } from './i18n.js';
11+
import type { PackageRemovalState } from './packageRemovalContents.js';
12+
13+
export function PackageRemovalSummary({
14+
state,
15+
draftCount,
16+
dropsTables,
17+
locale,
18+
}: {
19+
state: PackageRemovalState | null;
20+
/** How many pending drafts the delete also removes. */
21+
draftCount: number;
22+
/** The author chose to drop the object tables with their records. */
23+
dropsTables: boolean;
24+
locale: SupportedLocale;
25+
}) {
26+
if (!state) return null;
27+
const objects = state.status === 'ready' ? state.contents.objects : [];
28+
// Nothing is dropped from a package that holds no object; while the list is
29+
// read, or when it could not be read, the line stands.
30+
const showPermanent = dropsTables && !(state.status === 'ready' && objects.length === 0);
31+
return (
32+
<div className="grid gap-2 text-sm" data-testid="pkg-detail-delete-contents">
33+
<p className="font-medium">{t('engine.packages.detail.deleteContentsHeading', locale)}</p>
34+
{state.status === 'loading' && (
35+
<p className="text-xs text-muted-foreground" data-testid="pkg-detail-delete-contents-loading">
36+
{t('engine.packages.detail.deleteContentsLoading', locale)}
37+
</p>
38+
)}
39+
{state.status === 'failed' && (
40+
<p className="text-xs text-muted-foreground" data-testid="pkg-detail-delete-contents-failed">
41+
{t('engine.packages.detail.deleteContentsFailed', locale)}
42+
</p>
43+
)}
44+
{state.status === 'ready' && (
45+
<ul className="grid max-h-48 gap-1 overflow-y-auto rounded-md border p-2 text-xs">
46+
{objects.map((o) => (
47+
<li
48+
key={o.name}
49+
className="flex items-baseline justify-between gap-3"
50+
data-testid={`pkg-detail-delete-object-${o.name}`}
51+
>
52+
<span className="min-w-0 break-words">
53+
{o.label}
54+
{o.label !== o.name && <span className="ml-1 font-mono text-muted-foreground">{o.name}</span>}
55+
</span>
56+
<span className="shrink-0 tabular-nums text-muted-foreground">
57+
{recordCountText(state.counts[o.name], locale)}
58+
</span>
59+
</li>
60+
))}
61+
{state.contents.others.map((o) => (
62+
<li
63+
key={o.type}
64+
className="flex items-baseline justify-between gap-3"
65+
data-testid={`pkg-detail-delete-type-${o.type}`}
66+
>
67+
<span className="min-w-0 break-words">{translateMetadataType(o.type, locale)}</span>
68+
<span className="shrink-0 tabular-nums text-muted-foreground">{o.count}</span>
69+
</li>
70+
))}
71+
{objects.length === 0 && state.contents.others.length === 0 && (
72+
<li className="text-muted-foreground">{t('engine.packages.detail.deleteContentsEmpty', locale)}</li>
73+
)}
74+
</ul>
75+
)}
76+
{draftCount > 0 && (
77+
<p className="text-xs text-muted-foreground" data-testid="pkg-detail-delete-drafts">
78+
{draftCount === 1
79+
? tFormat('engine.packages.detail.deleteDraftsOne', locale, { count: draftCount })
80+
: tFormat('engine.packages.detail.deleteDraftsOther', locale, { count: draftCount })}
81+
</p>
82+
)}
83+
{showPermanent && (
84+
<p
85+
role="alert"
86+
className="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive"
87+
data-testid="pkg-detail-delete-permanent"
88+
>
89+
{t('engine.packages.detail.deletePermanent', locale)}
90+
</p>
91+
)}
92+
</div>
93+
);
94+
}
95+
96+
function recordCountText(count: number | null | undefined, locale: SupportedLocale): string {
97+
if (count === undefined) return '…';
98+
if (count === null) return t('engine.packages.detail.deleteRecordsUnknown', locale);
99+
return count === 1
100+
? tFormat('engine.packages.detail.deleteRecordsOne', locale, { count })
101+
: tFormat('engine.packages.detail.deleteRecordsOther', locale, { count });
102+
}

‎ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3030
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
3131
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
3232

33+
// The page reaches both through a dynamic `import()`; loaded here so the first
34+
// load is paid at collection, never inside a clocked case.
35+
import '@objectstack/formula';
36+
import '@objectstack/lint';
3337
import { PackageDetailSheet, PackagesPage, type InstalledPackageRow } from './PackagesPage';
3438

39+
/**
40+
* objectstack#31025 — the data source the Delete dialog counts each object's
41+
* rows through. `null` (no provider), as before, unless a test installs one.
42+
*/
43+
let adapter: { find: ReturnType<typeof vi.fn> } | null = null;
44+
vi.mock('@object-ui/react', async (importOriginal) => ({
45+
...(await importOriginal<typeof import('@object-ui/react')>()),
46+
useAdapter: () => adapter,
47+
}));
48+
/** What `GET /packages/:id/export` answers; a test may replace it. */
49+
let exportAnswer: unknown;
50+
3551
const PKG_ID = 'com.acme.crm';
3652
const PKG_NAME = 'Acme CRM';
3753
const PKG: InstalledPackageRow = {
@@ -72,6 +88,8 @@ function respond(body: unknown, status = 200) {
7288

7389
beforeEach(() => {
7490
calls = [];
91+
adapter = null;
92+
exportAnswer = { success: true, data: { id: PKG_ID, name: PKG_ID, version: '1.0.0' } };
7593
duplicateAnswer = { success: true, data: { success: true, copiedCount: 3, failedCount: 0 } };
7694
vi.stubGlobal(
7795
'fetch',
@@ -91,6 +109,9 @@ beforeEach(() => {
91109
if (input.endsWith('/duplicate')) {
92110
return respond(duplicateAnswer);
93111
}
112+
if (input === `/api/v1/packages/${PKG_ID}/export` && method === 'GET') {
113+
return respond(exportAnswer, (exportAnswer as { success?: boolean }).success === false ? 500 : 200);
114+
}
94115
return respond({ success: true, data: {} });
95116
}),
96117
);
@@ -430,3 +451,110 @@ describe('Console Packages page — a delete there stays on the page (objectui#1
430451
expect(screen.queryByTestId('elsewhere')).toBeNull();
431452
});
432453
});
454+
455+
describe('PackageDetailSheet — the Delete dialog states what goes (objectstack#31025)', () => {
456+
/** The export door's answer for a package with two objects and other items. */
457+
const EXPORT = {
458+
success: true,
459+
data: {
460+
id: PKG_ID,
461+
name: PKG_ID,
462+
version: '1.0.0',
463+
objects: [
464+
{ name: 'acme_lead', label: 'Lead', fields: {} },
465+
{ name: 'acme_deal', label: 'Deal', fields: {} },
466+
],
467+
views: [{ name: 'acme_lead_list' }],
468+
viewItems: [{ name: 'acme_deal_board' }],
469+
dashboards: [{ name: 'acme_home' }],
470+
flows: [{ name: 'acme_assign' }, { name: 'acme_notify' }],
471+
objectExtensions: [{ extend: 'sys_user' }],
472+
withheldMaps: [{ type: 'connector', name: 'acme_api', path: 'providerConfig.headers' }],
473+
},
474+
};
475+
const TOTALS: Record<string, number | undefined> = { acme_lead: 1234, acme_deal: 1 };
476+
477+
beforeEach(() => {
478+
exportAnswer = EXPORT;
479+
adapter = { find: vi.fn(async (name: string) => ({ data: [], total: TOTALS[name] })) };
480+
});
481+
482+
it('lists each object with the rows the data list counts, and other items by type', async () => {
483+
renderSheet();
484+
const dialog = await openDeleteDialog();
485+
const lead = await within(dialog).findByTestId('pkg-detail-delete-object-acme_lead');
486+
await waitFor(() => expect(lead).toHaveTextContent('1234 records'));
487+
expect(lead).toHaveTextContent('Lead');
488+
expect(within(dialog).getByTestId('pkg-detail-delete-object-acme_deal')).toHaveTextContent('1 record');
489+
// Each count is the data list asked for no rows, by object name.
490+
expect(adapter!.find.mock.calls.map((c) => c.slice(0, 2))).toEqual([
491+
['acme_lead', { $top: 0 }],
492+
['acme_deal', { $top: 0 }],
493+
]);
494+
// Counted by type: `views` and `viewItems` are both views.
495+
expect(within(dialog).getByTestId('pkg-detail-delete-type-view')).toHaveTextContent('2');
496+
expect(within(dialog).getByTestId('pkg-detail-delete-type-dashboard')).toHaveTextContent('1');
497+
expect(within(dialog).getByTestId('pkg-detail-delete-type-flow')).toHaveTextContent('2');
498+
expect(within(dialog).getByTestId('pkg-detail-delete-type-object_extension')).toHaveTextContent('1');
499+
// The withheld-map positions are not items of the package.
500+
expect(within(dialog).queryByTestId('pkg-detail-delete-type-withheldMaps')).toBeNull();
501+
// The pending drafts the sheet read go too.
502+
expect(within(dialog).getByTestId('pkg-detail-delete-drafts')).toHaveTextContent('2 unpublished drafts');
503+
// Reading what goes writes nothing.
504+
expect(writes()).toEqual([]);
505+
});
506+
507+
it('states the permanent drop only once the author chooses to drop the data', async () => {
508+
renderSheet();
509+
const dialog = await openDeleteDialog();
510+
await within(dialog).findByTestId('pkg-detail-delete-object-acme_lead');
511+
expect(within(dialog).queryByTestId('pkg-detail-delete-permanent')).toBeNull();
512+
fireEvent.click(within(dialog).getByTestId('pkg-detail-delete-mode-structure'));
513+
expect(within(dialog).queryByTestId('pkg-detail-delete-permanent')).toBeNull();
514+
fireEvent.click(within(dialog).getByTestId('pkg-detail-delete-mode-all'));
515+
expect(within(dialog).getByTestId('pkg-detail-delete-permanent')).toHaveTextContent(
516+
'Dropped tables do not go to the recycle bin',
517+
);
518+
fireEvent.click(within(dialog).getByTestId('pkg-detail-delete-mode-structure'));
519+
expect(within(dialog).queryByTestId('pkg-detail-delete-permanent')).toBeNull();
520+
});
521+
522+
it('a package with no object drops no table, so the permanent line is not drawn', async () => {
523+
exportAnswer = { success: true, data: { id: PKG_ID, name: PKG_ID, version: '1.0.0', flows: [{ name: 'f' }] } };
524+
renderSheet();
525+
const dialog = await openDeleteDialog();
526+
await within(dialog).findByTestId('pkg-detail-delete-type-flow');
527+
fireEvent.click(within(dialog).getByTestId('pkg-detail-delete-mode-all'));
528+
expect(within(dialog).queryByTestId('pkg-detail-delete-permanent')).toBeNull();
529+
});
530+
531+
it('a count the data list does not answer is unknown, never 0', async () => {
532+
adapter = { find: vi.fn(async (name: string) => (name === 'acme_lead' ? { data: [] } : Promise.reject(new Error('x')))) };
533+
renderSheet();
534+
const dialog = await openDeleteDialog();
535+
const lead = await within(dialog).findByTestId('pkg-detail-delete-object-acme_lead');
536+
await waitFor(() => expect(lead).toHaveTextContent('Record count unavailable'));
537+
await waitFor(() =>
538+
expect(within(dialog).getByTestId('pkg-detail-delete-object-acme_deal')).toHaveTextContent(
539+
'Record count unavailable',
540+
),
541+
);
542+
expect(lead).not.toHaveTextContent(/\b0 records/);
543+
});
544+
545+
it('an export the server refuses is said, and the delete can still be confirmed', async () => {
546+
exportAnswer = { success: false, error: { code: 'INTERNAL_ERROR', message: 'boom' } };
547+
renderSheet();
548+
const dialog = await openDeleteDialog();
549+
await within(dialog).findByTestId('pkg-detail-delete-contents-failed');
550+
fireEvent.click(within(dialog).getByTestId('pkg-detail-delete-mode-all'));
551+
// Not known to hold no object, so the permanent line stands.
552+
expect(within(dialog).getByTestId('pkg-detail-delete-permanent')).toBeInTheDocument();
553+
fireEvent.change(within(dialog).getByTestId('pkg-detail-delete-name-input'), {
554+
target: { value: PKG_NAME },
555+
});
556+
fireEvent.click(within(dialog).getByTestId('pkg-detail-delete-confirm'));
557+
await waitFor(() => expect(deletes()).toHaveLength(1));
558+
expect(deletes()[0].url).toBe(`/api/v1/packages/${PKG_ID}`);
559+
});
560+
});

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ import {
7777
import { useMetadataLocale, t, tFormat } from './i18n.js';
7878
import { useMetadataClient } from './useMetadata.js';
7979
import { PackageFormDialog } from './PackageFormDialog.js';
80+
import { readPackageRemovalContents, type PackageRemovalState } from './packageRemovalContents.js';
81+
import { PackageRemovalSummary } from './PackageRemovalSummary.js';
82+
import { useAdapter } from '@object-ui/react';
8083
import { PageHeader } from '../../layout/PageHeader.js';
8184
import { errorCodeIs } from '@object-ui/types';
8285
import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';
@@ -554,6 +557,15 @@ export function PackageDetailSheet({
554557
const [disableOpen, setDisableOpen] = React.useState(false);
555558
const [deleteMode, setDeleteMode] = React.useState<DeleteMode>('');
556559
const [deleteTyped, setDeleteTyped] = React.useState('');
560+
// objectstack#31025 — what the delete removes, read when its dialog opens.
561+
const [removal, setRemoval] = React.useState<PackageRemovalState | null>(null);
562+
// The count reads key on the package id alone (AGENTS.md #10): the adapter
563+
// is read through a ref, kept current from an effect.
564+
const adapter = useAdapter();
565+
const adapterRef = React.useRef(adapter);
566+
React.useEffect(() => {
567+
adapterRef.current = adapter;
568+
});
557569
const [discardOpen, setDiscardOpen] = React.useState(false);
558570
const [dupOpen, setDupOpen] = React.useState(false);
559571
const [dupName, setDupName] = React.useState('');
@@ -590,6 +602,43 @@ export function PackageDetailSheet({
590602
};
591603
}, [open, pkg?.manifest.id]);
592604

605+
// objectstack#31025 — the Delete dialog states what goes: the export door's
606+
// items (each object with the rows the data list counts), read each time
607+
// the dialog opens (`openDelete` marks it loading).
608+
const removalPackageId = deleteOpen ? pkg?.manifest.id : undefined;
609+
React.useEffect(() => {
610+
if (!removalPackageId) return;
611+
let cancelled = false;
612+
apiJson<unknown>(`${API}/${encodeURIComponent(removalPackageId)}/export`)
613+
.then((manifest) => {
614+
if (cancelled) return;
615+
const contents = readPackageRemovalContents(manifest);
616+
setRemoval({ status: 'ready', contents, counts: {} });
617+
const source = adapterRef.current;
618+
for (const { name } of contents.objects) {
619+
const counted: Promise<number | null> =
620+
source && typeof source.find === 'function'
621+
? source
622+
.find(name, { $top: 0 })
623+
.then((r) => (typeof r?.total === 'number' ? r.total : null))
624+
.catch(() => null)
625+
: Promise.resolve(null);
626+
void counted.then((count) => {
627+
if (cancelled) return;
628+
setRemoval((prev) =>
629+
prev?.status === 'ready' ? { ...prev, counts: { ...prev.counts, [name]: count } } : prev,
630+
);
631+
});
632+
}
633+
})
634+
.catch(() => {
635+
if (!cancelled) setRemoval({ status: 'failed' });
636+
});
637+
return () => {
638+
cancelled = true;
639+
};
640+
}, [removalPackageId]);
641+
593642
if (!pkg) return null;
594643
const id = pkg.manifest.id;
595644
const enabled = pkg.enabled !== false && pkg.status !== 'disabled';
@@ -813,6 +862,7 @@ export function PackageDetailSheet({
813862
startAction(() => {
814863
setDeleteMode('');
815864
setDeleteTyped('');
865+
setRemoval({ status: 'loading' });
816866
setDeleteOpen(true);
817867
});
818868

@@ -1290,6 +1340,12 @@ export function PackageDetailSheet({
12901340
</Label>
12911341
</div>
12921342
</RadioGroup>
1343+
<PackageRemovalSummary
1344+
state={removal}
1345+
draftCount={drafts?.length ?? 0}
1346+
dropsTables={deleteMode === 'all'}
1347+
locale={locale}
1348+
/>
12931349
<div className="grid gap-1.5">
12941350
<Label htmlFor={`${fieldId}-delete-name`} className="text-sm font-medium">
12951351
{tFormat('engine.packages.detail.deleteTypeName', locale, { name: displayName })}

0 commit comments

Comments
 (0)