Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { EditPackageDialog, type InstalledPackage } from './PackagesPage';

// PackagesPage.apiJson uses the raw global fetch (fetch → res.text() → JSON.parse),
// so we stub global fetch and mirror the runtime's { success, data } envelope —
// exactly what `PATCH /api/v1/packages/:id` returns (framework http-dispatcher).
const PKG: InstalledPackage = {
manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', description: 'old', type: 'app' },
enabled: true,
status: 'installed',
};

let calls: Array<{ url: string; init: RequestInit }>;

beforeEach(() => {
calls = [];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init: RequestInit = {}) => {
calls.push({ url, init });
// Echo the patched manifest back, the way the dispatcher does.
const body = init.body ? JSON.parse(init.body as string) : {};
const updated: InstalledPackage = {
...PKG,
manifest: { ...PKG.manifest, ...body },
};
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ success: true, data: updated }),
} as Response;
}),
);
});

afterEach(() => vi.unstubAllGlobals());

describe('EditPackageDialog', () => {
it('prefills from the manifest and PATCHes only the edited fields', async () => {
const onSaved = vi.fn();
const onOpenChange = vi.fn();
render(<EditPackageDialog pkg={PKG} open onOpenChange={onOpenChange} onSaved={onSaved} />);

const nameInput = (await screen.findByTestId('package-edit-name-input')) as HTMLInputElement;
expect(nameInput.value).toBe('Acme CRM'); // prefilled

fireEvent.change(nameInput, { target: { value: 'Acme CRM v2' } });
fireEvent.click(screen.getByTestId('package-edit-save'));

await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));

// Issued a PATCH to the package's REST id with the edited manifest fields.
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/v1/packages/com.acme.crm');
expect(calls[0].init.method).toBe('PATCH');
const sent = JSON.parse(calls[0].init.body as string);
expect(sent).toMatchObject({ name: 'Acme CRM v2', version: '1.0.0' });

// onSaved receives the server's updated package; the dialog closes.
expect(onSaved.mock.calls[0][0].manifest.name).toBe('Acme CRM v2');
expect(onOpenChange).toHaveBeenCalledWith(false);
});

it('blocks save on a non-semantic version and never calls the API', async () => {
render(<EditPackageDialog pkg={PKG} open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
const versionInput = await screen.findByLabelText(/version/i);
fireEvent.change(versionInput, { target: { value: '1.2' } });

const save = screen.getByTestId('package-edit-save') as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(save);
expect(calls).toHaveLength(0);
});

it('surfaces a server error and keeps the dialog open', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 400,
text: async () => JSON.stringify({ success: false, error: { message: 'version must be semantic (e.g. 1.0.0)' } }),
})),
);
const onSaved = vi.fn();
const onOpenChange = vi.fn();
render(<EditPackageDialog pkg={PKG} open onOpenChange={onOpenChange} onSaved={onSaved} />);

fireEvent.click(screen.getByTestId('package-edit-save'));

expect(await screen.findByText(/version must be semantic/i)).toBeInTheDocument();
expect(onSaved).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
138 changes: 138 additions & 0 deletions packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
Trash2,
Copy,
Inbox,
Pencil,
} from 'lucide-react';
import {
Button,
Expand Down Expand Up @@ -307,6 +308,128 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
);
}

/**
* Edit an existing package's manifest (name / description / version) via
* `PATCH /api/v1/packages/:id`. Mirrors CreatePackageDialog's standard-form
* shape; `id` / `scope` / `type` are immutable and not shown as inputs.
*/
export function EditPackageDialog({
pkg,
open,
onOpenChange,
onSaved,
}: {
pkg: InstalledPackage | null;
open: boolean;
onOpenChange: (v: boolean) => void;
onSaved: (updated: InstalledPackage) => void;
}) {
const locale = React.useMemo(() => detectLocale(), []);
const [name, setName] = React.useState('');
const [description, setDescription] = React.useState('');
const [version, setVersion] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(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<InstalledPackage>(`${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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('engine.packages.edit.title', locale)}</DialogTitle>
<DialogDescription className="font-mono text-xs">{pkg?.manifest.id}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="pkg-edit-name">{t('engine.packages.create.name', locale)}</Label>
<Input
id="pkg-edit-name"
data-testid="package-edit-name-input"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="pkg-edit-desc">{t('engine.packages.detail.description', locale)}</Label>
<Input
id="pkg-edit-desc"
data-testid="package-edit-desc-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="pkg-edit-version">{t('engine.packages.create.version', locale)}</Label>
<Input
id="pkg-edit-version"
value={version}
onChange={(e) => setVersion(e.target.value)}
aria-invalid={!!version.trim() && !versionValid}
/>
{!!version.trim() && !versionValid && (
<p className="text-xs text-destructive">{t('engine.packages.create.versionInvalid', locale)}</p>
)}
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
{t('engine.cancel', locale)}
</Button>
<Button onClick={submit} disabled={!canSubmit} data-testid="package-edit-save">
{busy ? t('engine.packages.edit.saving', locale) : t('engine.packages.edit.save', locale)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

export function PackageDetailSheet({
pkg,
appBase,
Expand All @@ -331,6 +454,7 @@ export function PackageDetailSheet({
// package looks empty right after a build. We list them here with a link to
// the existing per-item review/diff (?review=1) so the user can publish them.
const [drafts, setDrafts] = React.useState<Array<{ type: string; name: string }> | null>(null);
const [editOpen, setEditOpen] = React.useState(false);

React.useEffect(() => {
setMsg(null);
Expand Down Expand Up @@ -668,6 +792,10 @@ export function PackageDetailSheet({
{t('engine.packages.detail.actions', locale)}
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={!!busy}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
{t('engine.packages.detail.edit', locale)}
</Button>
<Button size="sm" onClick={publish} disabled={!!busy}>
<Upload className="mr-1.5 h-3.5 w-3.5" />
{busy === 'publish' ? t('engine.packages.detail.publishing', locale) : t('engine.packages.detail.publish', locale)}
Expand Down Expand Up @@ -721,6 +849,16 @@ export function PackageDetailSheet({
{msg.text}
</div>
)}

<EditPackageDialog
pkg={pkg}
open={editOpen}
onOpenChange={setEditOpen}
onSaved={() => {
setMsg({ kind: 'ok', text: t('engine.packages.edit.saved', locale) });
onChanged();
}}
/>
</SheetContent>
</Sheet>
);
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.packages.detail.discardChanges': 'Discard changes ({count})',
'engine.packages.detail.kernelReadOnly': 'This is a platform kernel package. Authoring actions are disabled.',
'engine.packages.detail.actions': 'Actions',
'engine.packages.detail.edit': 'Edit',
'engine.packages.edit.title': 'Edit package',
'engine.packages.edit.save': 'Save changes',
'engine.packages.edit.saving': 'Saving…',
'engine.packages.edit.saved': 'Package updated',
'engine.packages.edit.failed': 'Failed to update package',
'engine.packages.detail.publish': 'Publish',
'engine.packages.detail.publishing': 'Publishing…',
'engine.packages.detail.revert': 'Revert',
Expand Down Expand Up @@ -1622,6 +1628,12 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.packages.detail.discardChanges': '丢弃更改({count})',
'engine.packages.detail.kernelReadOnly': '这是平台内核软件包,已禁用编写操作。',
'engine.packages.detail.actions': '操作',
'engine.packages.detail.edit': '编辑',
'engine.packages.edit.title': '编辑软件包',
'engine.packages.edit.save': '保存更改',
'engine.packages.edit.saving': '保存中…',
'engine.packages.edit.saved': '软件包已更新',
'engine.packages.edit.failed': '更新软件包失败',
'engine.packages.detail.publish': '发布',
'engine.packages.detail.publishing': '发布中…',
'engine.packages.detail.revert': '还原',
Expand Down
74 changes: 46 additions & 28 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,32 +204,39 @@ function PackageSwitcher({ packageId, tab }: { packageId: string; tab: string })
// Open the standard detail/management sheet for a package — fetch its full
// installed record (manifest + status) first, since the switcher only holds
// the trimmed {id,name,writable} view.
const openManage = React.useCallback(async (id: string) => {
setOpen(false);
setManageBusy(true);
try {
const res = await fetch('/api/v1/packages', {
credentials: 'include',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
const data = (await res.json()) as unknown;
const root = (data as { data?: unknown })?.data ?? data;
const list = (Array.isArray(root) ? root : ((root as { packages?: unknown[] })?.packages ?? [])) as Array<
InstalledPackage & { id?: string }
>;
const full = list.find((p) => (p?.manifest?.id ?? p?.id) === id) ?? null;
setManage(full);
setManageOpen(true);
} catch (e) {
toast.error(formatMetadataError(e));
} finally {
setManageBusy(false);
}
const fetchFullPackage = React.useCallback(async (id: string): Promise<InstalledPackage | null> => {
const res = await fetch('/api/v1/packages', {
credentials: 'include',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
const data = (await res.json()) as unknown;
const root = (data as { data?: unknown })?.data ?? data;
const list = (Array.isArray(root) ? root : ((root as { packages?: unknown[] })?.packages ?? [])) as Array<
InstalledPackage & { id?: string }
>;
return list.find((p) => (p?.manifest?.id ?? p?.id) === id) ?? null;
}, []);

// A lifecycle action ran in the sheet — refresh the list. If the managed
// package was the one we're editing and it's now gone (deleted), leave it.
const openManage = React.useCallback(
async (id: string) => {
setOpen(false);
setManageBusy(true);
try {
setManage(await fetchFullPackage(id));
setManageOpen(true);
} catch (e) {
toast.error(formatMetadataError(e));
} finally {
setManageBusy(false);
}
},
[fetchFullPackage],
);

// A lifecycle action ran in the sheet — refresh the list AND the managed
// snapshot (so an edit shows immediately). If the managed package was the one
// we're editing and it's now gone (deleted), jump to another package / home.
const onManageChanged = React.useCallback(async () => {
let list: PkgEntry[] = [];
try {
Expand All @@ -239,11 +246,22 @@ function PackageSwitcher({ packageId, tab }: { packageId: string; tab: string })
/* keep the stale list */
}
const managedId = manage?.manifest.id;
if (managedId && managedId === packageId && !list.some((p) => p.id === managedId)) {
const next = list[0];
navigate(next ? `/studio/${encodeURIComponent(next.id)}/${tab}` : '/home');
if (!managedId) return;
if (!list.some((p) => p.id === managedId)) {
// Deleted — only navigate away if it was the package we're editing.
if (managedId === packageId) {
const next = list[0];
navigate(next ? `/studio/${encodeURIComponent(next.id)}/${tab}` : '/home');
}
return;
}
try {
const fresh = await fetchFullPackage(managedId);
if (fresh) setManage(fresh);
} catch {
/* keep the current snapshot */
}
}, [manage, packageId, tab, navigate]);
}, [manage, packageId, tab, navigate, fetchFullPackage]);

return (
// Radix Popover (portaled to <body>) — the top bar is `overflow-x-auto`,
Expand Down
Loading