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 (
+
+ );
+}
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 (
-
+ 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 (
-
+ 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 && (
<>