Skip to content

Commit 0d707b6

Browse files
os-zhuangclaude
andauthored
feat(app-shell): marketplace:installed-list SDUI widget (Installed Apps body) (#1666)
cloud ADR-0009 P2a: the Installed Apps page migrates to plugin-carried metadata; the console contributes only this registered widget. The legacy React route renders the SAME component during the migration window — single implementation, two entries. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b09ece7 commit 0d707b6

4 files changed

Lines changed: 226 additions & 182 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
`marketplace:installed-list` SDUI widget — the Installed Apps body (control-plane/local dual-source list, refresh, uninstall) extracted from the React route page, which now renders the same component. The page shell ships as metadata with `@objectstack/cloud-connection`'s install-local plugin (cloud ADR-0009 P2a).
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Installed-packages list — registered as the SDUI widget
5+
* `marketplace:installed-list` (cloud ADR-0009 P2a).
6+
*
7+
* The Installed Apps page ships as METADATA with
8+
* `@objectstack/cloud-connection`'s install-local plugin; this widget is
9+
* the interactive body: load (control-plane list when cloud-connected,
10+
* local install-local cache otherwise), refresh, uninstall. The legacy
11+
* React route (`MarketplaceInstalledPage`) renders the SAME component
12+
* during the ADR-0009 migration window — single implementation, two
13+
* entries.
14+
*/
15+
16+
import { useEffect, useState } from 'react';
17+
import { useNavigate, useParams } from 'react-router-dom';
18+
import {
19+
Card,
20+
CardContent,
21+
CardHeader,
22+
CardTitle,
23+
Badge,
24+
Button,
25+
Skeleton,
26+
} from '@object-ui/components';
27+
import { RefreshCcw, Trash2, AlertCircle, ExternalLink } from 'lucide-react';
28+
import { useIsWorkspaceAdmin } from '@object-ui/auth';
29+
import { useObjectTranslation } from '@object-ui/i18n';
30+
import { ComponentRegistry } from '@object-ui/core';
31+
import {
32+
listLocalInstalls,
33+
listInstalledPackages,
34+
uninstallLocal,
35+
type LocalInstallEntry,
36+
} from './marketplaceApi';
37+
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied';
38+
39+
export function InstalledList() {
40+
const navigate = useNavigate();
41+
const { appName } = useParams<{ appName?: string }>();
42+
const isAdmin = useIsWorkspaceAdmin();
43+
const { t, language } = useObjectTranslation();
44+
const basePath = appName ? `/apps/${appName}` : '';
45+
46+
const [items, setItems] = useState<LocalInstallEntry[]>([]);
47+
// 'cloud' = list comes from the control plane (CLI/marketplace/REST installs,
48+
// ADR-0007 step ①); 'local' = self-hosted install-local cache.
49+
const [source, setSource] = useState<'cloud' | 'local'>('local');
50+
const [loading, setLoading] = useState(true);
51+
const [working, setWorking] = useState<string | null>(null);
52+
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
53+
54+
const load = async () => {
55+
setLoading(true);
56+
try {
57+
// Cloud-connected env → authoritative installed list is the control
58+
// plane's. Self-hosted (not bound) → fall back to the local cache.
59+
const cloud = await listInstalledPackages();
60+
if (cloud.connected) {
61+
setSource('cloud');
62+
setItems(cloud.items);
63+
} else {
64+
setSource('local');
65+
setItems(await listLocalInstalls());
66+
}
67+
} finally {
68+
setLoading(false);
69+
}
70+
};
71+
72+
useEffect(() => { void load(); }, []);
73+
74+
const doUninstall = async (entry: LocalInstallEntry) => {
75+
if (!confirm(t('marketplace.uninstall.confirm', { manifestId: entry.manifestId, version: entry.version }))) {
76+
return;
77+
}
78+
setWorking(entry.manifestId);
79+
setResult(null);
80+
try {
81+
await uninstallLocal(entry.manifestId);
82+
setResult({
83+
ok: true,
84+
message: t('marketplace.uninstall.successInList', { manifestId: entry.manifestId }),
85+
});
86+
await load();
87+
} catch (e: any) {
88+
setResult({ ok: false, message: e?.message ?? String(e) });
89+
} finally {
90+
setWorking(null);
91+
}
92+
};
93+
94+
if (!isAdmin) return <MarketplaceAccessDenied />;
95+
96+
return (
97+
<div className="flex flex-col gap-4">
98+
<div className="flex justify-end">
99+
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
100+
<RefreshCcw className="h-4 w-4 mr-1.5" aria-hidden="true" />
101+
{t('marketplace.refresh')}
102+
</Button>
103+
</div>
104+
105+
{result && (
106+
<div className={`rounded-md border p-3 text-sm ${result.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
107+
<div className="flex items-start gap-2">
108+
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />
109+
<div>{result.message}</div>
110+
</div>
111+
</div>
112+
)}
113+
114+
{loading && items.length === 0 ? (
115+
<div className="grid gap-3">
116+
{Array.from({ length: 3 }).map((_, i) => (
117+
<Skeleton key={i} className="h-20 w-full" />
118+
))}
119+
</div>
120+
) : items.length === 0 ? (
121+
<div className="text-center py-12 text-sm text-muted-foreground border rounded-md">
122+
<p>{t('marketplace.installedEmpty')}</p>
123+
<Button
124+
variant="link"
125+
className="mt-2"
126+
onClick={() => navigate(`${basePath}/system/marketplace`)}
127+
>
128+
{t('marketplace.browseLink')}
129+
</Button>
130+
</div>
131+
) : (
132+
<div className="grid gap-3">
133+
{items.map((entry) => (
134+
<Card key={entry.manifestId}>
135+
<CardHeader className="flex flex-row items-start justify-between gap-3 pb-2">
136+
<div className="min-w-0">
137+
<CardTitle className="text-base truncate flex items-center gap-2">
138+
{entry.manifestId}
139+
<Badge variant="outline">{t('marketplace.versionBadge', { version: entry.version })}</Badge>
140+
</CardTitle>
141+
<div className="text-xs text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1">
142+
<span>{t('marketplace.installedAt', { when: new Date(entry.installedAt).toLocaleString(language || undefined) })}</span>
143+
{entry.installedBy && <span>{t('marketplace.installedBy', { user: entry.installedBy })}</span>}
144+
<span>{t('marketplace.installedPackageId')} <code className="font-mono">{entry.packageId}</code></span>
145+
</div>
146+
</div>
147+
<div className="flex items-center gap-2 shrink-0">
148+
<Button
149+
variant="ghost"
150+
size="sm"
151+
onClick={() => navigate(`${basePath}/system/marketplace/${entry.packageId}`)}
152+
>
153+
<ExternalLink className="h-4 w-4 mr-1.5" aria-hidden="true" />
154+
{t('marketplace.action.details')}
155+
</Button>
156+
{source === 'local' && (
157+
<Button
158+
variant="outline"
159+
size="sm"
160+
onClick={() => void doUninstall(entry)}
161+
disabled={working === entry.manifestId}
162+
>
163+
<Trash2 className="h-4 w-4 mr-1.5" aria-hidden="true" />
164+
{working === entry.manifestId ? t('marketplace.action.uninstalling') : t('marketplace.action.uninstall')}
165+
</Button>
166+
)}
167+
</div>
168+
</CardHeader>
169+
{source === 'local' && (
170+
<CardContent
171+
className="pt-0 text-xs text-muted-foreground"
172+
dangerouslySetInnerHTML={{
173+
__html: t('marketplace.cachedAs', {
174+
path: `.objectstack/installed-packages/${entry.manifestId.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`,
175+
}),
176+
}}
177+
/>
178+
)}
179+
</Card>
180+
))}
181+
</div>
182+
)}
183+
184+
<p
185+
className="text-xs text-muted-foreground border-t pt-4"
186+
dangerouslySetInnerHTML={{ __html: t('marketplace.installedAdditiveNote') }}
187+
/>
188+
</div>
189+
);
190+
}
191+
192+
// SDUI registration — the metadata page shipped by the install-local plugin
193+
// references this widget by type.
194+
ComponentRegistry.register('marketplace:installed-list', () => <InstalledList />, {
195+
namespace: 'app-shell',
196+
label: 'Installed Marketplace Packages',
197+
category: 'plugin',
198+
inputs: [],
199+
});

0 commit comments

Comments
 (0)