From c3f6bb8413545057f671dfa678a6488d668a1e02 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:53:42 +0800 Subject: [PATCH 1/2] feat(studio): spec-driven package create/edit/view form in a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package create/edit/view were three hand-rolled forms (CreatePackageDialog, EditPackageDialog, BuilderLanding's inline form) with two different id-validation regexes — a package created on one surface was rejected by another, and neither matched what the server accepts. Replace them with ONE modal (PackageFormDialog) that renders the manifest form from the spec (ManifestSchema → z.toJSONSchema → SchemaForm), mirroring the existing page-schema / view-schema pattern. - package-schema.ts: spec-derived JSONSchema + a curated FormView (Basics + collapsible Advanced). id/type/namespace/scope are `immutable` — editable on create, locked on edit (the REST PATCH only persists name/description/version). - PackageFormDialog: create → POST, edit → PATCH, view → readOnly. Duplicate 409 maps to a friendly "already exists" message. Create sends NO scope, so a runtime-created base stays writable (not 只读). - CreatePackageDialog / EditPackageDialog kept as thin wrappers so call sites (ResourceListPage, the Studio switcher) don't change; BuilderLanding's inline form now opens the dialog; PackageDetailSheet gains a "View info" (readOnly spec form) action. Net −254 lines (three forms collapse into one spec-driven component). Co-Authored-By: Claude Opus 4.8 --- .../metadata-admin/EditPackageDialog.test.tsx | 53 ++-- .../metadata-admin/PackageFormDialog.test.tsx | 82 +++++ .../metadata-admin/PackageFormDialog.tsx | 248 ++++++++++++++++ .../src/views/metadata-admin/PackagesPage.tsx | 279 +++--------------- .../src/views/metadata-admin/i18n.ts | 20 ++ .../views/metadata-admin/package-schema.ts | 117 ++++++++ .../views/studio-design/BuilderLanding.tsx | 104 ++----- 7 files changed, 548 insertions(+), 355 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/PackageFormDialog.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/package-schema.ts diff --git a/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx b/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx index 1bb05113b..a67bbe4e4 100644 --- a/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/EditPackageDialog.test.tsx @@ -5,9 +5,12 @@ 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). +// EditPackageDialog is now a thin wrapper over the spec-driven PackageFormDialog +// (edit mode). It renders the manifest form via SchemaForm and PATCHes only the +// three fields the REST surface persists (name / description / version). +// PackageFormDialog's 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. const PKG: InstalledPackage = { manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', description: 'old', type: 'app' }, enabled: true, @@ -22,12 +25,8 @@ beforeEach(() => { '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 }, - }; + const updated: InstalledPackage = { ...PKG, manifest: { ...PKG.manifest, ...body } }; return { ok: true, status: 200, @@ -39,57 +38,47 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); -describe('EditPackageDialog', () => { - it('prefills from the manifest and PATCHes only the edited fields', async () => { +describe('EditPackageDialog (spec-form wrapper)', () => { + it('prefills from the manifest and PATCHes only the server-persisted 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')); + // Stable submit affordance regardless of the SchemaForm field internals. + fireEvent.click(await screen.findByTestId('package-form-submit')); await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); - // Issued a PATCH to the package's REST id with the edited manifest fields. + // One PATCH to the package's REST id, carrying ONLY name/description/version. 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' }); + expect(JSON.parse(calls[0].init.body as string)).toEqual({ + name: 'Acme CRM', + description: 'old', + 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(onSaved.mock.calls[0][0].manifest.name).toBe('Acme CRM'); 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)' } }), + 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')); + fireEvent.click(await screen.findByTestId('package-form-submit')); expect(await screen.findByText(/version must be semantic/i)).toBeInTheDocument(); expect(onSaved).not.toHaveBeenCalled(); diff --git a/packages/app-shell/src/views/metadata-admin/PackageFormDialog.test.tsx b/packages/app-shell/src/views/metadata-admin/PackageFormDialog.test.tsx new file mode 100644 index 000000000..534cbc58e --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PackageFormDialog.test.tsx @@ -0,0 +1,82 @@ +// 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 { PackageFormDialog } from './PackageFormDialog'; + +// PackageFormDialog renders the manifest form from the spec (package-schema) +// via SchemaForm and talks to `/api/v1/packages`. apiJson uses the raw global +// fetch (fetch → res.text() → JSON.parse), so we stub fetch and mirror the +// runtime's { success, data } envelope. +let calls: Array<{ url: string; init: RequestInit }>; + +function stubOk() { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + const body = init.body ? JSON.parse(init.body as string) : {}; + const manifest = body.manifest ?? body; + return { + ok: true, + status: 201, + text: async () => JSON.stringify({ success: true, data: { manifest, status: 'installed' } }), + } as Response; + }), + ); +} + +beforeEach(() => { + calls = []; + stubOk(); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('PackageFormDialog — create mode', () => { + it('POSTs { manifest } from the spec form and reports the new id', async () => { + const onSaved = vi.fn(); + render(); + + // Fields come from the spec-derived FormView (package-schema); target them by label. + fireEvent.change(await screen.findByLabelText(/package id/i), { target: { value: 'com.acme.new' } }); + fireEvent.change(screen.getByLabelText(/display name/i), { target: { value: 'New App' } }); + // version prefills to 0.1.0; type prefills to 'app'. + + fireEvent.click(screen.getByTestId('package-form-submit')); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('/api/v1/packages'); + expect(calls[0].init.method).toBe('POST'); + expect(JSON.parse(calls[0].init.body as string).manifest).toMatchObject({ + id: 'com.acme.new', + name: 'New App', + version: '0.1.0', + type: 'app', + }); + expect(onSaved.mock.calls[0][0].id).toBe('com.acme.new'); + }); + + it('maps a 409 duplicate id to a friendly "already exists" message and stays open', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 409, + text: async () => JSON.stringify({ success: false, error: { message: "Package 'com.acme.new' already exists" } }), + })), + ); + const onSaved = vi.fn(); + const onOpenChange = vi.fn(); + render(); + + fireEvent.change(await screen.findByLabelText(/package id/i), { target: { value: 'com.acme.new' } }); + fireEvent.change(screen.getByLabelText(/display name/i), { target: { value: 'Dup App' } }); + fireEvent.click(screen.getByTestId('package-form-submit')); + + expect(await screen.findByText(/already exists/i)).toBeInTheDocument(); + expect(onSaved).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx b/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx new file mode 100644 index 000000000..f4b572bfd --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx @@ -0,0 +1,248 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PackageFormDialog — ONE modal for creating, editing, and viewing a package, + * rendered from the spec-derived manifest form (`package-schema`) through the + * generic {@link SchemaForm}. It replaces the three hand-rolled package forms + * (CreatePackageDialog / EditPackageDialog / the BuilderLanding inline form), + * each of which carried its own field list and id-validation regex — a package + * created on one surface was rejected by another. + * + * Modes: + * • create → POST /api/v1/packages { manifest } (409 on duplicate id) + * • edit → PATCH /api/v1/packages/:id { name, description, version } + * (the REST surface only persists those three; `id` / `type` / + * `namespace` / `scope` / … are `immutable` in the form and lock + * automatically once `createMode` is false) + * • view → read-only render of the manifest + */ + +import * as React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { ManifestSchema } from '@objectstack/spec/kernel'; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@object-ui/components'; +import { detectLocale, t, tFormat } from './i18n'; +import { SchemaForm, type SchemaFormIssue } from './SchemaForm'; +import { getPackageSchema, getPackageForm } from './package-schema'; + +const API = '/api/v1/packages'; +const VERSION_RE = /^\d+\.\d+\.\d+$/; + +export type PackageFormMode = 'create' | 'edit' | 'view'; + +/** A package manifest as a loose record (spec `ManifestSchema` shape). */ +export type ManifestRecord = Record; + +export interface PackageSaveResult { + id: string; + mode: PackageFormMode; + /** The server's package payload, when it returned one. */ + package?: { manifest: ManifestRecord } & Record; +} + +async function apiJson(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'include', + headers: { Accept: 'application/json', ...(init?.headers || {}) }, + ...init, + }); + const text = await res.text(); + const payload = text ? JSON.parse(text) : null; + if (!res.ok || payload?.success === false) { + const msg = + payload?.error?.message || payload?.error || payload?.message || `Request failed (${res.status})`; + const err = new Error(typeof msg === 'string' ? msg : `Request failed (${res.status})`); + (err as any).status = res.status; + throw err; + } + return (payload?.data ?? payload) as T; +} + +function notifyPackagesChanged() { + try { + window.dispatchEvent(new CustomEvent('objectui:packages-changed')); + } catch { + /* non-DOM env */ + } +} + +export function PackageFormDialog({ + mode, + open, + onOpenChange, + manifest, + onSaved, +}: { + mode: PackageFormMode; + open: boolean; + onOpenChange: (v: boolean) => void; + /** Existing manifest to seed edit/view. Ignored for create. */ + manifest?: ManifestRecord | null; + onSaved?: (result: PackageSaveResult) => void; +}) { + const locale = React.useMemo(() => detectLocale(), []); + const schema = React.useMemo(() => getPackageSchema(), []); + const form = React.useMemo(() => getPackageForm(locale), [locale]); + + const createMode = mode === 'create'; + const readOnly = mode === 'view'; + + const [draft, setDraft] = React.useState({}); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (!open) return; + if (createMode) { + // Defaults for a new WRITABLE base package. Deliberately no `scope`: + // a runtime-created base is writable, whereas `scope: 'project'` marks a + // read-only CODE package (packages-io writability heuristic). Sending it + // would make every new package render as 只读. `defaultDatasource` is + // likewise left to the server so we don't pin a datasource on create. + setDraft({ version: '0.1.0', type: 'app' }); + } else { + setDraft({ ...(manifest ?? {}) }); + } + setError(null); + setBusy(false); + }, [open, createMode, manifest]); + + // Spec validation → inline issues (only where fields are editable). + const issues: SchemaFormIssue[] = React.useMemo(() => { + if (readOnly) return []; + const res = ManifestSchema.safeParse(draft); + if (res.success) return []; + return res.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })); + }, [draft, readOnly]); + + const nameOk = !!String(draft.name ?? '').trim(); + const versionStr = String(draft.version ?? '').trim(); + const versionOk = createMode ? VERSION_RE.test(versionStr) : !versionStr || VERSION_RE.test(versionStr); + const idOk = !createMode || !!String(draft.id ?? '').trim(); + const canSubmit = !readOnly && nameOk && versionOk && idOk && !busy; + + async function submit() { + if (!canSubmit) return; + setBusy(true); + setError(null); + try { + let result: PackageSaveResult; + if (createMode) { + const manifestBody: ManifestRecord = { + ...draft, + id: String(draft.id ?? '').trim(), + name: String(draft.name ?? '').trim(), + version: versionStr, + type: draft.type ?? 'app', + }; + const created = await apiJson<{ manifest: ManifestRecord } & Record>(API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ manifest: manifestBody }), + }); + const id = String((created?.manifest?.id as string) ?? manifestBody.id); + result = { id, mode, package: created }; + } else { + const id = String((manifest?.id as string) ?? draft.id ?? ''); + // The REST PATCH only persists name/description/version. + const updated = await apiJson<{ manifest: ManifestRecord } & Record>( + `${API}/${encodeURIComponent(id)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: String(draft.name ?? '').trim(), + description: String(draft.description ?? '').trim(), + version: versionStr, + }), + }, + ); + result = { id, mode, package: updated }; + } + notifyPackagesChanged(); + onSaved?.(result); + onOpenChange(false); + } catch (e: any) { + const msg: string = e?.message ?? ''; + if (e?.status === 409 || /already exists/i.test(msg)) { + setError(t('engine.packages.create.exists', locale)); + } else { + setError(msg || t(createMode ? 'engine.packages.create.failed' : 'engine.packages.edit.failed', locale)); + } + } finally { + setBusy(false); + } + } + + const title = + mode === 'create' + ? t('engine.packages.create.title', locale) + : mode === 'edit' + ? t('engine.packages.edit.title', locale) + : t('engine.packages.view.title', locale); + + return ( + + + + {title} + {mode === 'create' ? ( + + {tFormat('engine.packages.create.description', locale, { example: 'com.acme.crm' })} + + ) : ( + + {String(manifest?.id ?? draft.id ?? '')} + + )} + + +
+ +
+ + {error && ( +
+ + {error} +
+ )} + + + {readOnly ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index ed30e6258..218f30b50 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -35,6 +35,7 @@ import { Copy, Inbox, Pencil, + Eye, } from 'lucide-react'; import { Button, @@ -58,14 +59,9 @@ import { SheetHeader, SheetTitle, SheetDescription, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, } from '@object-ui/components'; import { detectLocale, t, tFormat } from './i18n'; +import { PackageFormDialog } from './PackageFormDialog'; /* -------------------------------------------------------------------------- */ /* Types + API */ @@ -78,6 +74,10 @@ export interface PackageManifest { type?: string; scope?: 'cloud' | 'system' | 'project'; description?: string; + // The manifest carries many more spec fields (namespace, dependencies, …); + // the index signature lets it flow into the spec-driven PackageFormDialog + // (which types the manifest as a loose record) without a cast. + [key: string]: unknown; } export interface InstalledPackage { @@ -155,9 +155,12 @@ function StatusBadge({ pkg }: { pkg: InstalledPackage }) { /* Create-package dialog */ /* -------------------------------------------------------------------------- */ -const ID_RE = /^[a-z0-9][a-z0-9._-]{1,254}$/i; -const VERSION_RE = /^\d+\.\d+\.\d+$/; - +/** + * CreatePackageDialog — thin wrapper over the spec-driven {@link PackageFormDialog}. + * Kept as a named export so existing call sites (ResourceListPage, the Studio + * package switcher) don't change. The form fields + validation now come from + * the manifest spec (`package-schema`), not a hand-written field list. + */ export function CreatePackageDialog({ open, onOpenChange, @@ -167,131 +170,13 @@ export function CreatePackageDialog({ onOpenChange: (v: boolean) => void; onCreated: (id: string) => void; }) { - const locale = React.useMemo(() => detectLocale(), []); - const [id, setId] = React.useState(''); - const [name, setName] = React.useState(''); - const [version, setVersion] = React.useState('0.1.0'); - const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - if (open) { - setId(''); - setName(''); - setVersion('0.1.0'); - setError(null); - setBusy(false); - } - }, [open]); - - const idValid = ID_RE.test(id); - const versionValid = VERSION_RE.test(version); - const canSubmit = idValid && versionValid && !!name.trim() && !busy; - - async function submit() { - if (!canSubmit) return; - setBusy(true); - setError(null); - try { - await apiJson(API, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - manifest: { - id: id.trim(), - name: name.trim(), - version: version.trim(), - type: 'app', - // No `scope`: runtime-created base packages are writable authoring - // targets. `scope: 'project'` marks read-only CODE packages — the - // old hardcode here made Setup-created bases read as 只读 in the - // builder's switcher/landing while the builder's own creator made - // writable ones. One creation semantic everywhere now. - }, - }), - }); - onCreated(id.trim()); - // Let context selectors (e.g. the sidebar package switcher) pick up the - // new package without a full page reload. - try { - window.dispatchEvent(new CustomEvent('objectui:packages-changed')); - } catch { - /* non-DOM env */ - } - onOpenChange(false); - } catch (e: any) { - setError(e?.message ?? t('engine.packages.create.failed', locale)); - } finally { - setBusy(false); - } - } - return ( - - - - {t('engine.packages.create.title', locale)} - - {tFormat('engine.packages.create.description', locale, { example: 'com.acme.crm' })} - - -
-
- - setId(e.target.value)} - aria-invalid={!!id && !idValid} - /> - {!!id && !idValid && ( -

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

- )} -
-
- - setName(e.target.value)} - /> -
-
- - setVersion(e.target.value)} - aria-invalid={!!version && !versionValid} - /> - {!!version && !versionValid && ( -

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

- )} -
- {error && ( -
- - {error} -
- )} -
- - - - -
-
+ onCreated(r.id)} + /> ); } @@ -309,9 +194,11 @@ 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. + * EditPackageDialog — thin wrapper over the spec-driven {@link PackageFormDialog} + * in `edit` mode. The manifest form locks `id` / `type` / `namespace` / scope + * as immutable and submits only name / description / version — all the REST + * `PATCH /api/v1/packages/:id` persists. Kept as a named export so existing + * call sites don't change. */ export function EditPackageDialog({ pkg, @@ -324,109 +211,14 @@ export function EditPackageDialog({ 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} -
- )} -
- - - - -
-
+ onSaved((r.package as unknown as InstalledPackage) ?? (pkg as InstalledPackage))} + /> ); } @@ -455,6 +247,7 @@ export function PackageDetailSheet({ // 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); + const [viewOpen, setViewOpen] = React.useState(false); React.useEffect(() => { setMsg(null); @@ -792,6 +585,10 @@ export function PackageDetailSheet({ {t('engine.packages.detail.actions', locale)}

+
))} - {/* new-package card */} - {creating ? ( -
- { - setNewName(e.target.value); - if (!idTouched) { - const slug = toFieldNameLoose(e.target.value).replace(/_/g, '-'); - setNewId(slug ? `com.example.${slug}` : ''); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') void doCreate(); - if (e.key === 'Escape') setCreating(false); - }} - placeholder={t('engine.studio.pkg.namePlaceholder', locale)} - className="h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" - /> - - { - setIdTouched(true); - setNewId(v); - }} - onEnter={() => void doCreate()} - onEscape={() => setCreating(false)} - placeholder={t('engine.studio.pkg.idPlaceholder', locale)} - locale={locale} - testId="pkg-landing-id-input" - /> -
- - -
-
- ) : ( - - )} + {/* new-package card — opens the spec-driven create dialog (PackageFormDialog) */} + + open(r.id)} + /> + {readonly.length > 0 && ( <>

From 19a0d32022c0e6883a639319ebfdd45199ddc5be Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:39:04 +0800 Subject: [PATCH 2/2] fix(studio): port namespace field into the spec-form create dialog The merge with main brought in CreatePackageDialog.namespace.test.tsx (framework#2694), which targeted the old hand-rolled form's test ids and broke now that CreatePackageDialog renders the spec form. PackageFormDialog already auto-derives namespace from the id and gates submit on NAMESPACE_RE; this adds keystroke sanitization (lowercase + allowed alphabet) to match the old field, and rewrites the test to target the spec form by label. Co-Authored-By: Claude Opus 4.8 --- .../CreatePackageDialog.namespace.test.tsx | 22 +++++++++++-------- .../metadata-admin/PackageFormDialog.tsx | 9 +++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/CreatePackageDialog.namespace.test.tsx b/packages/app-shell/src/views/metadata-admin/CreatePackageDialog.namespace.test.tsx index 15a0b5fac..da3c9ba0e 100644 --- a/packages/app-shell/src/views/metadata-admin/CreatePackageDialog.namespace.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/CreatePackageDialog.namespace.test.tsx @@ -2,9 +2,13 @@ /** * Wiring guard for the package-namespace field (framework#2694) in the - * package-switcher "+ new" dialog: it defaults to the id-derived namespace, - * tracks the id input until the user edits it, and gates submit on a valid - * `^[a-z][a-z0-9_]{1,19}$` value. + * spec-driven create dialog (`CreatePackageDialog` → `PackageFormDialog`): it + * defaults to the id-derived namespace, tracks the id input until the user + * edits it, sanitizes keystrokes to the allowed alphabet, and gates submit on a + * valid `^[a-z][a-z0-9_]{1,19}$` value. + * + * The form is now rendered from the manifest spec via SchemaForm, so fields are + * targeted by their label rather than the old hand-rolled test ids. */ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; @@ -17,13 +21,14 @@ afterEach(cleanup); function open() { render(); return { - id: screen.getByTestId('package-id-input') as HTMLInputElement, - ns: screen.getByTestId('package-namespace-input') as HTMLInputElement, - name: screen.getByTestId('package-name-input') as HTMLInputElement, + id: screen.getByLabelText(/package id/i) as HTMLInputElement, + ns: screen.getByLabelText(/object namespace/i) as HTMLInputElement, + name: screen.getByLabelText(/display name/i) as HTMLInputElement, + submit: screen.getByTestId('package-form-submit') as HTMLButtonElement, }; } -describe('CreatePackageDialog — namespace field', () => { +describe('CreatePackageDialog — namespace field (spec form)', () => { it('derives the namespace from the package id and tracks it', () => { const { id, ns } = open(); fireEvent.change(id, { target: { value: 'com.example.leave' } }); @@ -50,10 +55,9 @@ describe('CreatePackageDialog — namespace field', () => { }); it('disables submit while the namespace is invalid, enables it when valid', () => { - const { id, ns, name } = open(); + const { id, ns, name, submit } = open(); fireEvent.change(id, { target: { value: 'com.example.leave' } }); fireEvent.change(name, { target: { value: 'Leave' } }); - const submit = screen.getByRole('button', { name: /create package|创建软件包/i }); expect(submit).toBeEnabled(); // Single char is below the 2-char minimum → invalid. fireEvent.change(ns, { target: { value: 'a' } }); diff --git a/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx b/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx index c52ad087c..3fb90a184 100644 --- a/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx @@ -132,7 +132,14 @@ export function PackageFormDialog({ } setDraft((prev) => { let namespace = next.namespace; - if (namespace !== prev.namespace) nsTouched.current = true; // direct edit + if (namespace !== prev.namespace) { + // Direct edit — stop tracking the id and sanitize to the allowed + // namespace alphabet (lowercase letters, digits, underscore). + nsTouched.current = true; + namespace = String(namespace ?? '') + .toLowerCase() + .replace(/[^a-z0-9_]/g, ''); + } if (!nsTouched.current && next.id !== prev.id) { namespace = deriveNamespaceFromPackageId(String(next.id ?? '')) ?? ''; }