Skip to content

Commit 81ad9aa

Browse files
os-zhuangclaude
andauthored
feat(studio): package lifecycle UI — Duplicate / Adopt loose items / structure-only delete (ADR-0070 D4/D5/D6) (#1983)
PackageDetailSheet: Duplicate (POST /duplicate), Adopt loose items (POST /adopt-orphans, D5 migration), keep-data choice on delete (?keepData). D6 guardrail test: scope selector never defaults to the Local sentinel. +i18n EN/ZH. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 616157a commit 81ad9aa

4 files changed

Lines changed: 105 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
feat(studio): package lifecycle UI — Duplicate base, Adopt loose items, structure-only delete (ADR-0070 D4/D5/D6)
6+
7+
`PackageDetailSheet` gains the user-facing affordances for the package-as-
8+
lifecycle-unit work:
9+
- **Duplicate**`POST /packages/:id/duplicate` (clone a base into a new
10+
writable package; D4).
11+
- **Adopt loose items**`POST /packages/:id/adopt-orphans` (migrate every
12+
package-less orphan into this base; D5).
13+
- **Delete** now asks whether to drop records too (`?keepData`) — structure-only
14+
vs everything (D4 Q3).
15+
16+
D6 guardrail test: the scope selector never defaults to the package-less
17+
`Local / Custom` sentinel (`writableBaseOptions` excludes it; real bases sort
18+
first).

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
ExternalLink,
3333
AlertTriangle,
3434
Trash2,
35+
Copy,
36+
Inbox,
3537
} from 'lucide-react';
3638
import {
3739
Button,
@@ -455,10 +457,13 @@ function PackageDetailSheet({
455457
tFormat('engine.packages.detail.deleteConfirm', locale, { name: pkg?.manifest.name || id }),
456458
);
457459
if (!ok) return;
460+
// ADR-0070 D4 (Q3) — let the user keep records (delete structure only).
461+
const alsoData = window.confirm(t('engine.packages.detail.deleteKeepData', locale));
462+
const qs = alsoData ? '' : '?keepData=true';
458463
setBusy('delete');
459464
setMsg(null);
460465
try {
461-
await apiJson(`${API}/${encodeURIComponent(id)}`, { method: 'DELETE' });
466+
await apiJson(`${API}/${encodeURIComponent(id)}${qs}`, { method: 'DELETE' });
462467
onChanged();
463468
onOpenChange(false);
464469
} catch (e: any) {
@@ -468,6 +473,46 @@ function PackageDetailSheet({
468473
}
469474
};
470475

476+
// ADR-0070 D4 — duplicate this base into a NEW writable package (re-namespaced).
477+
const duplicateApp = async () => {
478+
const target = window.prompt(t('engine.packages.detail.duplicatePrompt', locale), `${id}-copy`);
479+
if (!target || !target.trim()) return;
480+
setBusy('duplicate');
481+
setMsg(null);
482+
try {
483+
await apiJson(`${API}/${encodeURIComponent(id)}/duplicate`, {
484+
method: 'POST',
485+
headers: { 'Content-Type': 'application/json' },
486+
body: JSON.stringify({ targetPackageId: target.trim(), targetName: `${pkg?.manifest.name ?? id} (copy)` }),
487+
});
488+
setMsg({ kind: 'ok', text: t('engine.packages.detail.duplicated', locale) });
489+
onChanged();
490+
} catch (e: any) {
491+
setMsg({ kind: 'err', text: e?.message ?? 'Duplicate failed' });
492+
} finally {
493+
setBusy(null);
494+
}
495+
};
496+
497+
// ADR-0070 D5 — adopt every package-less (loose) item in this env INTO this base.
498+
const adoptOrphans = async () => {
499+
const ok = window.confirm(
500+
tFormat('engine.packages.detail.adoptConfirm', locale, { name: pkg?.manifest.name || id }),
501+
);
502+
if (!ok) return;
503+
setBusy('adopt');
504+
setMsg(null);
505+
try {
506+
await apiJson(`${API}/${encodeURIComponent(id)}/adopt-orphans`, { method: 'POST' });
507+
setMsg({ kind: 'ok', text: t('engine.packages.detail.adopted', locale) });
508+
onChanged();
509+
} catch (e: any) {
510+
setMsg({ kind: 'err', text: e?.message ?? 'Adopt failed' });
511+
} finally {
512+
setBusy(null);
513+
}
514+
};
515+
471516
const toggleEnable = () =>
472517
run(
473518
'toggle',
@@ -613,6 +658,14 @@ function PackageDetailSheet({
613658
<Download className="mr-1.5 h-3.5 w-3.5" />
614659
{busy === 'export' ? t('engine.packages.detail.exporting', locale) : t('engine.packages.detail.export', locale)}
615660
</Button>
661+
<Button size="sm" variant="outline" onClick={duplicateApp} disabled={!!busy}>
662+
<Copy className="mr-1.5 h-3.5 w-3.5" />
663+
{busy === 'duplicate' ? t('engine.packages.detail.duplicating', locale) : t('engine.packages.detail.duplicate', locale)}
664+
</Button>
665+
<Button size="sm" variant="outline" onClick={adoptOrphans} disabled={!!busy}>
666+
<Inbox className="mr-1.5 h-3.5 w-3.5" />
667+
{busy === 'adopt' ? t('engine.packages.detail.adopting', locale) : t('engine.packages.detail.adoptOrphans', locale)}
668+
</Button>
616669
<Button
617670
size="sm"
618671
variant="outline"

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,15 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
640640
'engine.packages.detail.disabled': 'Package disabled.',
641641
'engine.packages.detail.enabled': 'Package enabled.',
642642
'engine.packages.detail.exported': 'Package exported.',
643+
'engine.packages.detail.duplicate': 'Duplicate',
644+
'engine.packages.detail.duplicating': 'Duplicating…',
645+
'engine.packages.detail.duplicatePrompt': 'New package id for the duplicate (a fresh writable base):',
646+
'engine.packages.detail.duplicated': 'Package duplicated into a new base.',
647+
'engine.packages.detail.adoptOrphans': 'Adopt loose items',
648+
'engine.packages.detail.adopting': 'Adopting…',
649+
'engine.packages.detail.adoptConfirm': 'Move all package-less (loose) metadata in this environment INTO "{name}"? This rebinds orphaned items to this base.',
650+
'engine.packages.detail.adopted': 'Loose items adopted into this base.',
651+
'engine.packages.detail.deleteKeepData': 'Delete the DATA too?\n\nOK = also drop all records (destructive). Cancel = keep records, delete only the structure.',
643652
'engine.quickfind.placeholder': "Find metadata types or items… (try 'view', 'account')",
644653
'engine.quickfind.empty': 'Type to search across all metadata types.',
645654
'engine.quickfind.title': 'Quick Find',
@@ -1334,6 +1343,15 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
13341343
'engine.packages.detail.disabled': '软件包已禁用。',
13351344
'engine.packages.detail.enabled': '软件包已启用。',
13361345
'engine.packages.detail.exported': '软件包已导出。',
1346+
'engine.packages.detail.duplicate': '复制',
1347+
'engine.packages.detail.duplicating': '复制中…',
1348+
'engine.packages.detail.duplicatePrompt': '副本的新软件包 id(一个全新的可写基座):',
1349+
'engine.packages.detail.duplicated': '软件包已复制为新基座。',
1350+
'engine.packages.detail.adoptOrphans': '收编散落项',
1351+
'engine.packages.detail.adopting': '收编中…',
1352+
'engine.packages.detail.adoptConfirm': '把本环境中所有无软件包(散落)的元数据移动到 "{name}" 吗?这会把孤儿项重新绑定到此基座。',
1353+
'engine.packages.detail.adopted': '散落项已收编进此基座。',
1354+
'engine.packages.detail.deleteKeepData': '同时删除数据吗?\n\n确定 = 同时删除所有记录(破坏性)。取消 = 保留记录,仅删除结构。',
13371355
'engine.quickfind.placeholder': '搜索元数据类型或条目…(如:view、account)',
13381356
'engine.quickfind.empty': '输入关键字以搜索所有元数据类型。',
13391357
'engine.quickfind.title': '快速查找',

packages/app-shell/src/views/metadata-admin/package-scope.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,18 @@ describe('package-scope (ADR-0070)', () => {
4141
expect(isLocalScope('app.acme.crm')).toBe(false);
4242
});
4343
});
44+
45+
describe('package-scope D6 guardrail (ADR-0070 — no package-less default)', () => {
46+
const raw = [
47+
{ manifest: { id: 'app.acme.crm', name: 'CRM', scope: 'project' } },
48+
{ manifest: { id: 'platform.core', name: 'Core', scope: 'system' } },
49+
];
50+
it('never makes the Local sentinel the default scope (a real base sorts first, Local last)', () => {
51+
const opts = buildPackageScopeOptions(raw);
52+
expect(opts[0].id).not.toBe(LOCAL_PACKAGE_ID);
53+
expect(opts[opts.length - 1].id).toBe(LOCAL_PACKAGE_ID);
54+
});
55+
it('the create-scope source (writableBaseOptions) excludes the Local sentinel entirely', () => {
56+
expect(writableBaseOptions(raw).some((o) => o.id === LOCAL_PACKAGE_ID)).toBe(false);
57+
});
58+
});

0 commit comments

Comments
 (0)