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 ( + + + + {t('engine.packages.edit.title', locale)} + {pkg?.manifest.id} + +
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ + setVersion(e.target.value)} + aria-invalid={!!version.trim() && !versionValid} + /> + {!!version.trim() && !versionValid && ( +

{t('engine.packages.create.versionInvalid', locale)}

+ )} +
+ {error && ( +
+ + {error} +
+ )} +
+ + + + +
+
+ ); +} + 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`,