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/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..3fb90a184 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx @@ -0,0 +1,285 @@ +// 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, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; +import { NAMESPACE_RE } from '../studio-design/packages-io'; +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); + // Object-name namespace (framework#2694) tracks the id-derived default until + // the user edits it directly. + const nsTouched = React.useRef(false); + + React.useEffect(() => { + if (!open) return; + nsTouched.current = false; + 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]); + + // On create, keep `namespace` in sync with the id (deriveNamespaceFromPackageId) + // until the user edits the namespace field themselves — mirroring the old + // create form's behaviour (framework#2694). SchemaForm hands us the full next + // value, so we diff id/namespace to decide. + const handleChange = React.useCallback( + (next: ManifestRecord) => { + if (!createMode) { + setDraft(next); + return; + } + setDraft((prev) => { + let namespace = next.namespace; + 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 ?? '')) ?? ''; + } + return { ...next, namespace }; + }); + }, + [createMode], + ); + + // 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(); + // Namespace is required on create (framework#2694): every object name is + // prefixed with it. On edit it's immutable and not resubmitted. + const nsOk = !createMode || NAMESPACE_RE.test(String(draft.namespace ?? '').trim()); + const canSubmit = !readOnly && nameOk && versionOk && idOk && nsOk && !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 bad658ebe..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,16 +59,9 @@ import { SheetHeader, SheetTitle, SheetDescription, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, } from '@object-ui/components'; import { detectLocale, t, tFormat } from './i18n'; -import { deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; -import { NAMESPACE_RE } from '../studio-design/packages-io'; +import { PackageFormDialog } from './PackageFormDialog'; /* -------------------------------------------------------------------------- */ /* Types + API */ @@ -80,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 { @@ -157,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, @@ -169,166 +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'); - // Object-name namespace (framework#2694): defaults to the id-derived value and - // tracks the package-id input until the user edits it. Derived during render - // (no effect) — `namespace` state holds only the user's own edits. - const [namespace, setNamespace] = React.useState(''); - const [nsTouched, setNsTouched] = React.useState(false); - const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - if (open) { - setId(''); - setName(''); - setVersion('0.1.0'); - setNamespace(''); - setNsTouched(false); - setError(null); - setBusy(false); - } - }, [open]); - - const idValid = ID_RE.test(id); - const versionValid = VERSION_RE.test(version); - const effectiveNs = nsTouched ? namespace : (deriveNamespaceFromPackageId(id) ?? ''); - const nsValid = NAMESPACE_RE.test(effectiveNs.trim()); - const canSubmit = idValid && versionValid && nsValid && !!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', - // Object-name namespace (framework#2694). The framework back-derives - // it from the id when omitted, but the user may have edited it, so - // send the authored value explicitly. - namespace: effectiveNs.trim(), - // 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)} -

- )} -
-
- - { - setNsTouched(true); - setNamespace(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '')); - }} - aria-invalid={!!effectiveNs && !nsValid} - /> - {!!effectiveNs && !nsValid ? ( -

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

- ) : ( -

- {tFormat('engine.packages.create.namespaceHint', locale, { ns: effectiveNs.trim() || 'crm' })} -

- )} -
-
- - 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)} + /> ); } @@ -346,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, @@ -361,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))} + /> ); } @@ -492,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); @@ -829,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" - /> - { - setNsTouched(true); - setNewNs(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '')); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') void doCreate(); - if (e.key === 'Escape') setCreating(false); - }} - placeholder={t('engine.studio.pkg.namespacePlaceholder', locale)} - data-testid="pkg-landing-namespace-input" - aria-invalid={!!effectiveNs.trim() && !nsValid} - className="h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary aria-[invalid=true]:border-destructive" - /> - {effectiveNs.trim() && !nsValid ? ( -

{t('engine.studio.pkg.namespaceInvalid', locale)}

- ) : ( -

- {tFormat('engine.studio.pkg.namespaceHint', locale, { ns: effectiveNs.trim() || 'leave' })} -

- )} -
- - -
-
- ) : ( - - )} + {/* new-package card — opens the spec-driven create dialog (PackageFormDialog) */} + + open(r.id)} + /> + {readonly.length > 0 && ( <>