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 && (
+
+ )}
+
+
+ {readOnly ? (
+ onOpenChange(false)}>
+ {t('engine.close', locale)}
+
+ ) : (
+ <>
+ onOpenChange(false)} disabled={busy}>
+ {t('engine.cancel', locale)}
+
+
+ {busy
+ ? t(createMode ? 'engine.packages.create.creating' : 'engine.packages.edit.saving', locale)
+ : t(createMode ? 'engine.packages.create.submit' : 'engine.packages.edit.save', locale)}
+
+ >
+ )}
+
+
+
+ );
+}
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' })}
-
-
-
-
-
{t('engine.packages.create.id', locale)}
-
setId(e.target.value)}
- aria-invalid={!!id && !idValid}
- />
- {!!id && !idValid && (
-
- {t('engine.packages.create.idInvalid', locale)}
-
- )}
-
-
- {t('engine.packages.create.name', locale)}
- setName(e.target.value)}
- />
-
-
-
{t('engine.packages.create.version', locale)}
-
setVersion(e.target.value)}
- aria-invalid={!!version && !versionValid}
- />
- {!!version && !versionValid && (
-
{t('engine.packages.create.versionInvalid', locale)}
- )}
-
- {error && (
-
- )}
-
-
- onOpenChange(false)} disabled={busy}>
- {t('engine.cancel', locale)}
-
-
- {busy ? t('engine.packages.create.creating', locale) : t('engine.packages.create.submit', locale)}
-
-
-
-
+ 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}
-
-
-
- {t('engine.packages.create.name', locale)}
- setName(e.target.value)}
- />
-
-
- {t('engine.packages.detail.description', locale)}
- setDescription(e.target.value)}
- />
-
-
-
{t('engine.packages.create.version', locale)}
-
setVersion(e.target.value)}
- aria-invalid={!!version.trim() && !versionValid}
- />
- {!!version.trim() && !versionValid && (
-
{t('engine.packages.create.versionInvalid', locale)}
- )}
-
- {error && (
-
- )}
-
-
- onOpenChange(false)} disabled={busy}>
- {t('engine.cancel', locale)}
-
-
- {busy ? t('engine.packages.edit.saving', locale) : t('engine.packages.edit.save', locale)}
-
-
-
-
+ 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)}
+
setViewOpen(true)} disabled={!!busy}>
+
+ {t('engine.packages.detail.viewInfo', locale)}
+
setEditOpen(true)} disabled={!!busy}>
{t('engine.packages.detail.edit', locale)}
@@ -859,6 +656,12 @@ export function PackageDetailSheet({
onChanged();
}}
/>
+
);
diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts
index 9ec2c63e7..5b72a0402 100644
--- a/packages/app-shell/src/views/metadata-admin/i18n.ts
+++ b/packages/app-shell/src/views/metadata-admin/i18n.ts
@@ -540,6 +540,7 @@ const ENGINE_STRINGS_EN: Record = {
'The change would break existing references. Review the issues and confirm to force-save.',
'engine.edit.forceSave': 'Force save',
'engine.cancel': 'Cancel',
+ 'engine.close': 'Close',
'engine.form.select': 'Select...',
'engine.form.selectEllipsis': 'Select…',
'engine.form.add': 'Add',
@@ -636,6 +637,15 @@ const ENGINE_STRINGS_EN: Record = {
'engine.packages.create.creating': 'Creating…',
'engine.packages.create.submit': 'Create package',
'engine.packages.create.failed': 'Failed to create package',
+ 'engine.packages.create.exists': 'A package with this id already exists.',
+ 'engine.packages.form.basics': 'Basics',
+ 'engine.packages.form.advanced': 'Advanced',
+ 'engine.packages.form.namespace': 'Namespace',
+ 'engine.packages.form.defaultDatasource': 'Default datasource',
+ 'engine.packages.form.scope': 'Scope',
+ 'engine.packages.form.dependencies': 'Dependencies',
+ 'engine.packages.view.title': 'Package info',
+ 'engine.packages.detail.viewInfo': 'View info',
'engine.packages.detail.type': 'Type',
'engine.packages.detail.description': 'Description',
'engine.packages.detail.browseMetadata': "Browse this package's metadata",
@@ -1807,6 +1817,7 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.edit.destructiveHint': '该修改会破坏现有引用关系。请检查问题清单后确认强制保存。',
'engine.edit.forceSave': '强制保存',
'engine.cancel': '取消',
+ 'engine.close': '关闭',
'engine.form.select': '请选择...',
'engine.form.selectEllipsis': '请选择…',
'engine.form.add': '添加',
@@ -1903,6 +1914,15 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.packages.create.creating': '创建中…',
'engine.packages.create.submit': '创建软件包',
'engine.packages.create.failed': '创建软件包失败',
+ 'engine.packages.create.exists': '已存在相同 ID 的软件包。',
+ 'engine.packages.form.basics': '基本信息',
+ 'engine.packages.form.advanced': '高级',
+ 'engine.packages.form.namespace': '命名空间',
+ 'engine.packages.form.defaultDatasource': '默认数据源',
+ 'engine.packages.form.scope': '范围',
+ 'engine.packages.form.dependencies': '依赖',
+ 'engine.packages.view.title': '软件包信息',
+ 'engine.packages.detail.viewInfo': '查看信息',
'engine.packages.detail.type': '类型',
'engine.packages.detail.description': '描述',
'engine.packages.detail.browseMetadata': '浏览此软件包的元数据',
diff --git a/packages/app-shell/src/views/metadata-admin/package-schema.ts b/packages/app-shell/src/views/metadata-admin/package-schema.ts
new file mode 100644
index 000000000..953959967
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/package-schema.ts
@@ -0,0 +1,117 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * package-schema.ts — the SINGLE source of truth for package-manifest
+ * authoring metadata, sourced directly from `@objectstack/spec/kernel`
+ * (`ManifestSchema`) rather than hand-written field lists. Mirrors
+ * {@link ./page-schema} / view-schema / report-schema.
+ *
+ * Why: package create / edit / view used to be THREE hand-rolled forms
+ * (CreatePackageDialog, EditPackageDialog, BuilderLanding's inline form) with
+ * two DIFFERENT id regexes — a package created via one surface was rejected by
+ * another, and neither matched what the server accepts. Rendering the
+ * spec-derived form through {@link SchemaForm} gives one field definition and
+ * one validation everywhere.
+ *
+ * `ManifestSchema` carries 25+ fields, most of them authoring-time machine
+ * config (`contributes` / `packaging` / `integrity` / `engines` …). A curated
+ * FormView surfaces only the human-relevant subset; the rest stay in the
+ * schema (so a submitted manifest round-trips) but are hidden from the form.
+ *
+ * We convert the zod schema to JSONSchema once (memoised) via zod 4's native
+ * `z.toJSONSchema` and feed `{ form, schema }` straight into SchemaForm.
+ * Adding a manifest field in the spec flows through automatically — zero code
+ * changes here.
+ */
+
+import { z } from 'zod';
+import { ManifestSchema } from '@objectstack/spec/kernel';
+import type { FormViewSpec } from './SchemaForm';
+import { t } from './i18n';
+
+type JsonSchema = Record;
+
+const TO_JSON_OPTS = { io: 'input', unrepresentable: 'any' } as const;
+
+/**
+ * The curated fields the package form surfaces, in display order. Everything
+ * else on the manifest is machine/authoring config and is hidden from the
+ * form (but preserved on save).
+ */
+export const PACKAGE_FORM_FIELDS = [
+ 'name',
+ 'id',
+ 'version',
+ 'type',
+ 'description',
+ 'namespace',
+ 'defaultDatasource',
+ 'scope',
+ 'dependencies',
+] as const;
+
+let _schema: JsonSchema | undefined;
+let _schemaFailed = false;
+
+/** JSONSchema for the whole package manifest (memoised, spec-derived). */
+export function getPackageSchema(): JsonSchema | undefined {
+ if (_schema || _schemaFailed) return _schema;
+ try {
+ _schema = z.toJSONSchema(ManifestSchema, TO_JSON_OPTS) as JsonSchema;
+ } catch (err) {
+ _schemaFailed = true;
+ if (typeof console !== 'undefined') {
+ console.warn('[package-schema] failed to derive manifest JSONSchema from spec', err);
+ }
+ }
+ return _schema;
+}
+
+/**
+ * Curated authoring FormView for a package manifest.
+ *
+ * `id` / `type` / `namespace` / `defaultDatasource` / `scope` / `dependencies`
+ * are marked `immutable` — editable when CREATING (SchemaForm `createMode`)
+ * but locked once the package exists, because the REST `PATCH /packages/:id`
+ * only persists `name` / `description` / `version`. `name` / `version` /
+ * `description` stay editable in every non-view mode.
+ */
+export function getPackageForm(locale: string): FormViewSpec {
+ return {
+ type: 'modal',
+ sections: [
+ {
+ label: t('engine.packages.form.basics', locale),
+ columns: 1,
+ fields: [
+ { field: 'name', label: t('engine.packages.create.name', locale), placeholder: 'Acme CRM' },
+ {
+ field: 'id',
+ label: t('engine.packages.create.id', locale),
+ placeholder: 'com.acme.crm',
+ immutable: true,
+ },
+ { field: 'version', label: t('engine.packages.create.version', locale), placeholder: '0.1.0' },
+ { field: 'type', label: t('engine.packages.detail.type', locale), immutable: true },
+ { field: 'description', label: t('engine.packages.detail.description', locale) },
+ ],
+ },
+ {
+ label: t('engine.packages.form.advanced', locale),
+ collapsible: true,
+ collapsed: true,
+ columns: 1,
+ fields: [
+ { field: 'namespace', label: t('engine.packages.form.namespace', locale), immutable: true },
+ {
+ field: 'defaultDatasource',
+ label: t('engine.packages.form.defaultDatasource', locale),
+ immutable: true,
+ },
+ { field: 'scope', label: t('engine.packages.form.scope', locale), immutable: true },
+ { field: 'dependencies', label: t('engine.packages.form.dependencies', locale), immutable: true },
+ ],
+ },
+ ],
+ };
+}
diff --git a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx
index d91747cec..8451ceb61 100644
--- a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx
+++ b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx
@@ -18,21 +18,17 @@ import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Boxes, Hammer, Lock, Plus, Loader2, Copy } from 'lucide-react';
import { toast } from 'sonner';
-import { toFieldNameLoose } from '../metadata-admin/previews/object-fields-io';
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
-import { fetchPackages, createBasePackage, duplicatePackage, PACKAGE_ID_RE, type PkgEntry } from './packages-io';
-import { PackageIdInput, PackageIdSuggestionHint } from './PackageIdInput';
+import { PackageFormDialog } from '../metadata-admin/PackageFormDialog';
+import { fetchPackages, duplicatePackage, PACKAGE_ID_RE, type PkgEntry } from './packages-io';
+import { PackageIdInput } from './PackageIdInput';
export function BuilderLanding(): React.ReactElement {
const navigate = useNavigate();
const locale = useMetadataLocale();
const [pkgs, setPkgs] = React.useState(null);
const [error, setError] = React.useState(null);
- const [creating, setCreating] = React.useState(false);
- const [newName, setNewName] = React.useState('');
- const [newId, setNewId] = React.useState('');
- const [idTouched, setIdTouched] = React.useState(false);
- const [busy, setBusy] = React.useState(false);
+ const [createOpen, setCreateOpen] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
@@ -50,22 +46,6 @@ export function BuilderLanding(): React.ReactElement {
const open = (id: string) => navigate(`/studio/${encodeURIComponent(id)}/data`);
- const doCreate = async () => {
- const name = newName.trim();
- const id = newId.trim();
- if (!name || !PACKAGE_ID_RE.test(id)) return;
- setBusy(true);
- setError(null);
- try {
- await createBasePackage(id, name);
- open(id);
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- } finally {
- setBusy(false);
- }
- };
-
const writable = pkgs?.filter((p) => p.writable) ?? [];
const readonly = pkgs?.filter((p) => !p.writable) ?? [];
@@ -206,69 +186,23 @@ export function BuilderLanding(): React.ReactElement {
))}
- {/* 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"
- />
-
-
void doCreate()}
- disabled={busy || !newName.trim() || !PACKAGE_ID_RE.test(newId.trim())}
- className="inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50"
- >
- {busy ? : }
- {t('engine.studio.landing.createGo', locale)}
-
-
setCreating(false)}
- className="rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted"
- >
- {t('engine.studio.cancel', locale)}
-
-
-
- ) : (
- setCreating(true)}
- className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed px-3 py-2.5 text-xs text-muted-foreground hover:border-primary/50 hover:text-foreground"
- >
- {t('engine.studio.pkg.new', locale)}
-
- )}
+ {/* new-package card — opens the spec-driven create dialog (PackageFormDialog) */}
+ setCreateOpen(true)}
+ className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed px-3 py-2.5 text-xs text-muted-foreground hover:border-primary/50 hover:text-foreground"
+ >
+ {t('engine.studio.pkg.new', locale)}
+
+ 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 ?? '')) ?? '';
}