From 619f71bf8e6bacc0409cf607df39c856fcf2f22b Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 5 Jul 2026 16:52:35 +0000
Subject: [PATCH] feat(studio): edit a package's manifest from the detail sheet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-up to the package management work (#2297): the standard
PackageDetailSheet now has an "Edit" action opening a standard dialog to
change a package's name / description / version, wired to the new
framework endpoint `PATCH /api/v1/packages/:id`. `id` / `scope` / `type`
stay immutable (not shown as inputs).
- `EditPackageDialog` — standard Dialog, prefilled from the manifest,
semantic-version validation, inline error; PATCHes and returns the
updated package.
- Studio switcher: the manage sheet's snapshot now refreshes after any
lifecycle action (shared `fetchFullPackage`), so an edit shows
immediately instead of a stale view; delete-of-current still navigates
away.
- i18n: en + zh for the edit dialog.
- Integration test drives the real dialog: prefill → PATCH (asserts
URL / method / body) → server response → close; version-validation
gate; server-error path.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01DxVTSDSXrdnfqHNBkHB6yi
---
.../metadata-admin/EditPackageDialog.test.tsx | 98 +++++++++++++
.../src/views/metadata-admin/PackagesPage.tsx | 138 ++++++++++++++++++
.../src/views/metadata-admin/i18n.ts | 12 ++
.../studio-design/StudioDesignSurface.tsx | 74 ++++++----
4 files changed, 294 insertions(+), 28 deletions(-)
create mode 100644 packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx
diff --git a/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx b/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx
new file mode 100644
index 000000000..1bb05113b
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx
@@ -0,0 +1,98 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import * as React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { EditPackageDialog, type InstalledPackage } from './PackagesPage';
+
+// PackagesPage.apiJson uses the raw global fetch (fetch → res.text() → JSON.parse),
+// so we stub global fetch and mirror the runtime's { success, data } envelope —
+// exactly what `PATCH /api/v1/packages/:id` returns (framework http-dispatcher).
+const PKG: InstalledPackage = {
+ manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', description: 'old', type: 'app' },
+ enabled: true,
+ status: 'installed',
+};
+
+let calls: Array<{ url: string; init: RequestInit }>;
+
+beforeEach(() => {
+ calls = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string, init: RequestInit = {}) => {
+ calls.push({ url, init });
+ // Echo the patched manifest back, the way the dispatcher does.
+ const body = init.body ? JSON.parse(init.body as string) : {};
+ const updated: InstalledPackage = {
+ ...PKG,
+ manifest: { ...PKG.manifest, ...body },
+ };
+ return {
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify({ success: true, data: updated }),
+ } as Response;
+ }),
+ );
+});
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('EditPackageDialog', () => {
+ it('prefills from the manifest and PATCHes only the edited fields', async () => {
+ const onSaved = vi.fn();
+ const onOpenChange = vi.fn();
+ render();
+
+ const nameInput = (await screen.findByTestId('package-edit-name-input')) as HTMLInputElement;
+ expect(nameInput.value).toBe('Acme CRM'); // prefilled
+
+ fireEvent.change(nameInput, { target: { value: 'Acme CRM v2' } });
+ fireEvent.click(screen.getByTestId('package-edit-save'));
+
+ await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
+
+ // Issued a PATCH to the package's REST id with the edited manifest fields.
+ expect(calls).toHaveLength(1);
+ expect(calls[0].url).toBe('/api/v1/packages/com.acme.crm');
+ expect(calls[0].init.method).toBe('PATCH');
+ const sent = JSON.parse(calls[0].init.body as string);
+ expect(sent).toMatchObject({ name: 'Acme CRM v2', version: '1.0.0' });
+
+ // onSaved receives the server's updated package; the dialog closes.
+ expect(onSaved.mock.calls[0][0].manifest.name).toBe('Acme CRM v2');
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('blocks save on a non-semantic version and never calls the API', async () => {
+ render();
+ const versionInput = await screen.findByLabelText(/version/i);
+ fireEvent.change(versionInput, { target: { value: '1.2' } });
+
+ const save = screen.getByTestId('package-edit-save') as HTMLButtonElement;
+ expect(save.disabled).toBe(true);
+ fireEvent.click(save);
+ expect(calls).toHaveLength(0);
+ });
+
+ it('surfaces a server error and keeps the dialog open', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ ok: false,
+ status: 400,
+ text: async () => JSON.stringify({ success: false, error: { message: 'version must be semantic (e.g. 1.0.0)' } }),
+ })),
+ );
+ const onSaved = vi.fn();
+ const onOpenChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByTestId('package-edit-save'));
+
+ expect(await screen.findByText(/version must be semantic/i)).toBeInTheDocument();
+ expect(onSaved).not.toHaveBeenCalled();
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
index c2119201e..ed30e6258 100644
--- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
+++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
@@ -34,6 +34,7 @@ import {
Trash2,
Copy,
Inbox,
+ Pencil,
} from 'lucide-react';
import {
Button,
@@ -307,6 +308,128 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
);
}
+/**
+ * Edit an existing package's manifest (name / description / version) via
+ * `PATCH /api/v1/packages/:id`. Mirrors CreatePackageDialog's standard-form
+ * shape; `id` / `scope` / `type` are immutable and not shown as inputs.
+ */
+export function EditPackageDialog({
+ pkg,
+ open,
+ onOpenChange,
+ onSaved,
+}: {
+ pkg: InstalledPackage | null;
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ onSaved: (updated: InstalledPackage) => void;
+}) {
+ const locale = React.useMemo(() => detectLocale(), []);
+ const [name, setName] = React.useState('');
+ const [description, setDescription] = React.useState('');
+ const [version, setVersion] = React.useState('');
+ const [busy, setBusy] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ if (open && pkg) {
+ setName(pkg.manifest.name ?? '');
+ setDescription(pkg.manifest.description ?? '');
+ setVersion(pkg.manifest.version ?? '');
+ setError(null);
+ setBusy(false);
+ }
+ }, [open, pkg]);
+
+ const versionValid = !version.trim() || VERSION_RE.test(version.trim());
+ const canSubmit = !!name.trim() && versionValid && !busy;
+
+ async function submit() {
+ if (!canSubmit || !pkg) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const updated = await apiJson(`${API}/${encodeURIComponent(pkg.manifest.id)}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: name.trim(),
+ description: description.trim(),
+ version: version.trim(),
+ }),
+ });
+ try {
+ window.dispatchEvent(new CustomEvent('objectui:packages-changed'));
+ } catch {
+ /* non-DOM env */
+ }
+ onSaved(updated);
+ onOpenChange(false);
+ } catch (e: any) {
+ setError(e?.message ?? t('engine.packages.edit.failed', locale));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
export function PackageDetailSheet({
pkg,
appBase,
@@ -331,6 +454,7 @@ export function PackageDetailSheet({
// package looks empty right after a build. We list them here with a link to
// the existing per-item review/diff (?review=1) so the user can publish them.
const [drafts, setDrafts] = React.useState | null>(null);
+ const [editOpen, setEditOpen] = React.useState(false);
React.useEffect(() => {
setMsg(null);
@@ -668,6 +792,10 @@ export function PackageDetailSheet({
{t('engine.packages.detail.actions', locale)}
+
)}
+
+ {
+ setMsg({ kind: 'ok', text: t('engine.packages.edit.saved', locale) });
+ onChanged();
+ }}
+ />
);
diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts
index 2981de703..fdc26b982 100644
--- a/packages/app-shell/src/views/metadata-admin/i18n.ts
+++ b/packages/app-shell/src/views/metadata-admin/i18n.ts
@@ -646,6 +646,12 @@ const ENGINE_STRINGS_EN: Record = {
'engine.packages.detail.discardChanges': 'Discard changes ({count})',
'engine.packages.detail.kernelReadOnly': 'This is a platform kernel package. Authoring actions are disabled.',
'engine.packages.detail.actions': 'Actions',
+ 'engine.packages.detail.edit': 'Edit',
+ 'engine.packages.edit.title': 'Edit package',
+ 'engine.packages.edit.save': 'Save changes',
+ 'engine.packages.edit.saving': 'Saving…',
+ 'engine.packages.edit.saved': 'Package updated',
+ 'engine.packages.edit.failed': 'Failed to update package',
'engine.packages.detail.publish': 'Publish',
'engine.packages.detail.publishing': 'Publishing…',
'engine.packages.detail.revert': 'Revert',
@@ -1622,6 +1628,12 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.packages.detail.discardChanges': '丢弃更改({count})',
'engine.packages.detail.kernelReadOnly': '这是平台内核软件包,已禁用编写操作。',
'engine.packages.detail.actions': '操作',
+ 'engine.packages.detail.edit': '编辑',
+ 'engine.packages.edit.title': '编辑软件包',
+ 'engine.packages.edit.save': '保存更改',
+ 'engine.packages.edit.saving': '保存中…',
+ 'engine.packages.edit.saved': '软件包已更新',
+ 'engine.packages.edit.failed': '更新软件包失败',
'engine.packages.detail.publish': '发布',
'engine.packages.detail.publishing': '发布中…',
'engine.packages.detail.revert': '还原',
diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
index b7105beb3..cfc3d66c3 100644
--- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -204,32 +204,39 @@ function PackageSwitcher({ packageId, tab }: { packageId: string; tab: string })
// Open the standard detail/management sheet for a package — fetch its full
// installed record (manifest + status) first, since the switcher only holds
// the trimmed {id,name,writable} view.
- const openManage = React.useCallback(async (id: string) => {
- setOpen(false);
- setManageBusy(true);
- try {
- const res = await fetch('/api/v1/packages', {
- credentials: 'include',
- headers: { Accept: 'application/json' },
- cache: 'no-store',
- });
- const data = (await res.json()) as unknown;
- const root = (data as { data?: unknown })?.data ?? data;
- const list = (Array.isArray(root) ? root : ((root as { packages?: unknown[] })?.packages ?? [])) as Array<
- InstalledPackage & { id?: string }
- >;
- const full = list.find((p) => (p?.manifest?.id ?? p?.id) === id) ?? null;
- setManage(full);
- setManageOpen(true);
- } catch (e) {
- toast.error(formatMetadataError(e));
- } finally {
- setManageBusy(false);
- }
+ const fetchFullPackage = React.useCallback(async (id: string): Promise => {
+ const res = await fetch('/api/v1/packages', {
+ credentials: 'include',
+ headers: { Accept: 'application/json' },
+ cache: 'no-store',
+ });
+ const data = (await res.json()) as unknown;
+ const root = (data as { data?: unknown })?.data ?? data;
+ const list = (Array.isArray(root) ? root : ((root as { packages?: unknown[] })?.packages ?? [])) as Array<
+ InstalledPackage & { id?: string }
+ >;
+ return list.find((p) => (p?.manifest?.id ?? p?.id) === id) ?? null;
}, []);
- // A lifecycle action ran in the sheet — refresh the list. If the managed
- // package was the one we're editing and it's now gone (deleted), leave it.
+ const openManage = React.useCallback(
+ async (id: string) => {
+ setOpen(false);
+ setManageBusy(true);
+ try {
+ setManage(await fetchFullPackage(id));
+ setManageOpen(true);
+ } catch (e) {
+ toast.error(formatMetadataError(e));
+ } finally {
+ setManageBusy(false);
+ }
+ },
+ [fetchFullPackage],
+ );
+
+ // A lifecycle action ran in the sheet — refresh the list AND the managed
+ // snapshot (so an edit shows immediately). If the managed package was the one
+ // we're editing and it's now gone (deleted), jump to another package / home.
const onManageChanged = React.useCallback(async () => {
let list: PkgEntry[] = [];
try {
@@ -239,11 +246,22 @@ function PackageSwitcher({ packageId, tab }: { packageId: string; tab: string })
/* keep the stale list */
}
const managedId = manage?.manifest.id;
- if (managedId && managedId === packageId && !list.some((p) => p.id === managedId)) {
- const next = list[0];
- navigate(next ? `/studio/${encodeURIComponent(next.id)}/${tab}` : '/home');
+ if (!managedId) return;
+ if (!list.some((p) => p.id === managedId)) {
+ // Deleted — only navigate away if it was the package we're editing.
+ if (managedId === packageId) {
+ const next = list[0];
+ navigate(next ? `/studio/${encodeURIComponent(next.id)}/${tab}` : '/home');
+ }
+ return;
+ }
+ try {
+ const fresh = await fetchFullPackage(managedId);
+ if (fresh) setManage(fresh);
+ } catch {
+ /* keep the current snapshot */
}
- }, [manage, packageId, tab, navigate]);
+ }, [manage, packageId, tab, navigate, fetchFullPackage]);
return (
// Radix Popover (portaled to ) — the top bar is `overflow-x-auto`,