Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/adr-0070-package-lifecycle-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@object-ui/app-shell": patch
---

feat(studio): package lifecycle UI — Duplicate base, Adopt loose items, structure-only delete (ADR-0070 D4/D5/D6)

`PackageDetailSheet` gains the user-facing affordances for the package-as-
lifecycle-unit work:
- **Duplicate** → `POST /packages/:id/duplicate` (clone a base into a new
writable package; D4).
- **Adopt loose items** → `POST /packages/:id/adopt-orphans` (migrate every
package-less orphan into this base; D5).
- **Delete** now asks whether to drop records too (`?keepData`) — structure-only
vs everything (D4 Q3).

D6 guardrail test: the scope selector never defaults to the package-less
`Local / Custom` sentinel (`writableBaseOptions` excludes it; real bases sort
first).
55 changes: 54 additions & 1 deletion packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
ExternalLink,
AlertTriangle,
Trash2,
Copy,
Inbox,
} from 'lucide-react';
import {
Button,
Expand Down Expand Up @@ -455,10 +457,13 @@ function PackageDetailSheet({
tFormat('engine.packages.detail.deleteConfirm', locale, { name: pkg?.manifest.name || id }),
);
if (!ok) return;
// ADR-0070 D4 (Q3) — let the user keep records (delete structure only).
const alsoData = window.confirm(t('engine.packages.detail.deleteKeepData', locale));
const qs = alsoData ? '' : '?keepData=true';
setBusy('delete');
setMsg(null);
try {
await apiJson(`${API}/${encodeURIComponent(id)}`, { method: 'DELETE' });
await apiJson(`${API}/${encodeURIComponent(id)}${qs}`, { method: 'DELETE' });
onChanged();
onOpenChange(false);
} catch (e: any) {
Expand All @@ -468,6 +473,46 @@ function PackageDetailSheet({
}
};

// ADR-0070 D4 — duplicate this base into a NEW writable package (re-namespaced).
const duplicateApp = async () => {
const target = window.prompt(t('engine.packages.detail.duplicatePrompt', locale), `${id}-copy`);
if (!target || !target.trim()) return;
setBusy('duplicate');
setMsg(null);
try {
await apiJson(`${API}/${encodeURIComponent(id)}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetPackageId: target.trim(), targetName: `${pkg?.manifest.name ?? id} (copy)` }),
});
setMsg({ kind: 'ok', text: t('engine.packages.detail.duplicated', locale) });
onChanged();
} catch (e: any) {
setMsg({ kind: 'err', text: e?.message ?? 'Duplicate failed' });
} finally {
setBusy(null);
}
};

// ADR-0070 D5 — adopt every package-less (loose) item in this env INTO this base.
const adoptOrphans = async () => {
const ok = window.confirm(
tFormat('engine.packages.detail.adoptConfirm', locale, { name: pkg?.manifest.name || id }),
);
if (!ok) return;
setBusy('adopt');
setMsg(null);
try {
await apiJson(`${API}/${encodeURIComponent(id)}/adopt-orphans`, { method: 'POST' });
setMsg({ kind: 'ok', text: t('engine.packages.detail.adopted', locale) });
onChanged();
} catch (e: any) {
setMsg({ kind: 'err', text: e?.message ?? 'Adopt failed' });
} finally {
setBusy(null);
}
};

const toggleEnable = () =>
run(
'toggle',
Expand Down Expand Up @@ -613,6 +658,14 @@ function PackageDetailSheet({
<Download className="mr-1.5 h-3.5 w-3.5" />
{busy === 'export' ? t('engine.packages.detail.exporting', locale) : t('engine.packages.detail.export', locale)}
</Button>
<Button size="sm" variant="outline" onClick={duplicateApp} disabled={!!busy}>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{busy === 'duplicate' ? t('engine.packages.detail.duplicating', locale) : t('engine.packages.detail.duplicate', locale)}
</Button>
<Button size="sm" variant="outline" onClick={adoptOrphans} disabled={!!busy}>
<Inbox className="mr-1.5 h-3.5 w-3.5" />
{busy === 'adopt' ? t('engine.packages.detail.adopting', locale) : t('engine.packages.detail.adoptOrphans', locale)}
</Button>
<Button
size="sm"
variant="outline"
Expand Down
18 changes: 18 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,15 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.packages.detail.disabled': 'Package disabled.',
'engine.packages.detail.enabled': 'Package enabled.',
'engine.packages.detail.exported': 'Package exported.',
'engine.packages.detail.duplicate': 'Duplicate',
'engine.packages.detail.duplicating': 'Duplicating…',
'engine.packages.detail.duplicatePrompt': 'New package id for the duplicate (a fresh writable base):',
'engine.packages.detail.duplicated': 'Package duplicated into a new base.',
'engine.packages.detail.adoptOrphans': 'Adopt loose items',
'engine.packages.detail.adopting': 'Adopting…',
'engine.packages.detail.adoptConfirm': 'Move all package-less (loose) metadata in this environment INTO "{name}"? This rebinds orphaned items to this base.',
'engine.packages.detail.adopted': 'Loose items adopted into this base.',
'engine.packages.detail.deleteKeepData': 'Delete the DATA too?\n\nOK = also drop all records (destructive). Cancel = keep records, delete only the structure.',
'engine.quickfind.placeholder': "Find metadata types or items… (try 'view', 'account')",
'engine.quickfind.empty': 'Type to search across all metadata types.',
'engine.quickfind.title': 'Quick Find',
Expand Down Expand Up @@ -1334,6 +1343,15 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.packages.detail.disabled': '软件包已禁用。',
'engine.packages.detail.enabled': '软件包已启用。',
'engine.packages.detail.exported': '软件包已导出。',
'engine.packages.detail.duplicate': '复制',
'engine.packages.detail.duplicating': '复制中…',
'engine.packages.detail.duplicatePrompt': '副本的新软件包 id(一个全新的可写基座):',
'engine.packages.detail.duplicated': '软件包已复制为新基座。',
'engine.packages.detail.adoptOrphans': '收编散落项',
'engine.packages.detail.adopting': '收编中…',
'engine.packages.detail.adoptConfirm': '把本环境中所有无软件包(散落)的元数据移动到 "{name}" 吗?这会把孤儿项重新绑定到此基座。',
'engine.packages.detail.adopted': '散落项已收编进此基座。',
'engine.packages.detail.deleteKeepData': '同时删除数据吗?\n\n确定 = 同时删除所有记录(破坏性)。取消 = 保留记录,仅删除结构。',
'engine.quickfind.placeholder': '搜索元数据类型或条目…(如:view、account)',
'engine.quickfind.empty': '输入关键字以搜索所有元数据类型。',
'engine.quickfind.title': '快速查找',
Expand Down
15 changes: 15 additions & 0 deletions packages/app-shell/src/views/metadata-admin/package-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,18 @@ describe('package-scope (ADR-0070)', () => {
expect(isLocalScope('app.acme.crm')).toBe(false);
});
});

describe('package-scope D6 guardrail (ADR-0070 — no package-less default)', () => {
const raw = [
{ manifest: { id: 'app.acme.crm', name: 'CRM', scope: 'project' } },
{ manifest: { id: 'platform.core', name: 'Core', scope: 'system' } },
];
it('never makes the Local sentinel the default scope (a real base sorts first, Local last)', () => {
const opts = buildPackageScopeOptions(raw);
expect(opts[0].id).not.toBe(LOCAL_PACKAGE_ID);
expect(opts[opts.length - 1].id).toBe(LOCAL_PACKAGE_ID);
});
it('the create-scope source (writableBaseOptions) excludes the Local sentinel entirely', () => {
expect(writableBaseOptions(raw).some((o) => o.id === LOCAL_PACKAGE_ID)).toBe(false);
});
});
Loading