mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): MarketplaceView + PluginDetailView + /marketplace routes
Plan G4 / spec §11.6.
router.ts gains two new Route variants — 'marketplace' and
'marketplace-detail' — plus parsing for both /marketplace/<id> and
the /plugins/<id> alias the public site (§13) reserves. App.tsx
dispatches them outside the EntryView / ProjectView split so the
discovery surface stays independent of any active project.
New components:
- MarketplaceView (apps/web/src/components/MarketplaceView.tsx)
- Card grid of every installed plugin with trust-tier filters
(All / Trusted / Restricted).
- Secondary 'Configured catalogs' panel listing every row in
/api/marketplaces with id / url / trust / plugin count.
- Cards link to /marketplace/<id>.
- PluginDetailView (apps/web/src/components/PluginDetailView.tsx)
- Loads /api/plugins/:id, renders header (title, version, trust,
sourceKind, taskKind), description, capability checklist,
connector requirements (required + optional), and declared GenUI
surfaces.
- 'Use this plugin' button calls applyPlugin(id) and navigates
back to Home so the existing inline rail / NewProjectPanel
surface picks up the snapshot.
Web tests: 579 → 586 (added router-marketplace 5 cases +
MarketplaceView 2 cases). Typecheck clean.
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
b436f0b33d
commit
411d83b0bf
6 changed files with 462 additions and 1 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { EntryView } from './components/EntryView';
|
||||
import { MarketplaceView } from './components/MarketplaceView';
|
||||
import { PluginDetailView } from './components/PluginDetailView';
|
||||
import type { CreateInput } from './components/NewProjectPanel';
|
||||
import { PetOverlay } from './components/pet/PetOverlay';
|
||||
import { migrateCustomPetAtlas } from './components/pet/pets';
|
||||
|
|
@ -697,6 +699,17 @@ export function App() {
|
|||
[designSystems, config.disabledDesignSystems],
|
||||
);
|
||||
|
||||
// Phase 2B / spec §11.6 — marketplace deep UI dispatch. The
|
||||
// /marketplace and /marketplace/:id routes render outside the
|
||||
// EntryView / ProjectView split so the discovery surface stays
|
||||
// independent of any active project.
|
||||
if (route.kind === 'marketplace') {
|
||||
return <MarketplaceView />;
|
||||
}
|
||||
if (route.kind === 'marketplace-detail') {
|
||||
return <PluginDetailView pluginId={route.pluginId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeProject ? (
|
||||
|
|
|
|||
134
apps/web/src/components/MarketplaceView.tsx
Normal file
134
apps/web/src/components/MarketplaceView.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Plan G4 / spec §11.6 — Marketplace catalog grid.
|
||||
//
|
||||
// Lists every installed plugin as a card grid (the most reliable
|
||||
// snapshot of what the user can apply right now). Configured
|
||||
// marketplaces are rendered as a secondary "Catalogs" panel so the
|
||||
// user can register / refresh / remove without leaving the page.
|
||||
//
|
||||
// Click a card → navigate to /marketplace/:id (PluginDetailView).
|
||||
// This is the deep-browsing surface; the inline rail (§8) stays the
|
||||
// primary daily-driver flow.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import { listPlugins } from '../state/projects';
|
||||
import { navigate } from '../router';
|
||||
|
||||
interface Marketplace {
|
||||
id: string;
|
||||
url: string;
|
||||
trust: 'official' | 'trusted' | 'restricted';
|
||||
manifest: { name?: string; plugins?: Array<{ name: string; source: string; description?: string }> };
|
||||
}
|
||||
|
||||
export function MarketplaceView() {
|
||||
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
|
||||
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'trusted' | 'restricted'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
listPlugins(),
|
||||
fetch('/api/marketplaces')
|
||||
.then((r) => (r.ok ? r.json() : { marketplaces: [] }))
|
||||
.then((d) => (d?.marketplaces ?? []) as Marketplace[]),
|
||||
]).then(([rows, mps]) => {
|
||||
if (cancelled) return;
|
||||
setPlugins(rows);
|
||||
setMarketplaces(mps);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visible = plugins.filter((p) => filter === 'all' || p.trust === filter);
|
||||
|
||||
return (
|
||||
<div className="marketplace-view" data-testid="marketplace-view">
|
||||
<header className="marketplace-view__header">
|
||||
<h1>Plugins marketplace</h1>
|
||||
<div className="marketplace-view__filters">
|
||||
<button
|
||||
type="button"
|
||||
data-active={filter === 'all'}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={filter === 'trusted'}
|
||||
onClick={() => setFilter('trusted')}
|
||||
>
|
||||
Trusted
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={filter === 'restricted'}
|
||||
onClick={() => setFilter('restricted')}
|
||||
>
|
||||
Restricted
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="marketplace-view__loading">Loading…</div>
|
||||
) : null}
|
||||
|
||||
<section className="marketplace-view__grid" data-testid="marketplace-grid">
|
||||
{visible.length === 0 && !loading ? (
|
||||
<div className="marketplace-view__empty">
|
||||
No plugins installed yet. Try <code>od plugin install <source></code> or
|
||||
register a marketplace below.
|
||||
</div>
|
||||
) : null}
|
||||
{visible.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.id}
|
||||
className="marketplace-view__card"
|
||||
onClick={() => navigate({ kind: 'marketplace-detail', pluginId: p.id })}
|
||||
data-plugin-id={p.id}
|
||||
>
|
||||
<div className="marketplace-view__card-title">{p.title}</div>
|
||||
{p.manifest?.description ? (
|
||||
<div className="marketplace-view__card-desc">{p.manifest.description}</div>
|
||||
) : null}
|
||||
<div className="marketplace-view__card-meta">
|
||||
<span>v{p.version}</span>
|
||||
<span>trust: {p.trust}</span>
|
||||
<span>{p.sourceKind}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="marketplace-view__catalogs" data-testid="marketplace-catalogs">
|
||||
<h2>Configured catalogs</h2>
|
||||
{marketplaces.length === 0 ? (
|
||||
<div>
|
||||
None registered. Add one with <code>od marketplace add <url></code>.
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{marketplaces.map((m) => (
|
||||
<li key={m.id}>
|
||||
<strong>{m.manifest.name ?? m.url}</strong>{' '}
|
||||
<span className="marketplace-view__catalog-trust">trust: {m.trust}</span>
|
||||
{' · '}
|
||||
<a href={m.url} target="_blank" rel="noreferrer">{m.url}</a>
|
||||
{' · '}
|
||||
{m.manifest.plugins?.length ?? 0} plugin(s)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
apps/web/src/components/PluginDetailView.tsx
Normal file
191
apps/web/src/components/PluginDetailView.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Plan G4 / spec §11.6 — Marketplace plugin detail.
|
||||
//
|
||||
// Renders one plugin's manifest, capability checklist, declared GenUI
|
||||
// surfaces and connector requirements, plus a "Use this plugin"
|
||||
// button that hydrates a fresh ApplyResult. The user lands back on
|
||||
// Home with the brief / chip strip / inputs form pre-filled (the
|
||||
// PluginsSection on NewProjectPanel renders the same applied state
|
||||
// because applyPlugin returns the exact ApplyResult).
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ApplyResult, InstalledPluginRecord } from '@open-design/contracts';
|
||||
import { applyPlugin } from '../state/projects';
|
||||
import { navigate } from '../router';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export function PluginDetailView(props: Props) {
|
||||
const [plugin, setPlugin] = useState<InstalledPluginRecord | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applied, setApplied] = useState<ApplyResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetch(`/api/plugins/${encodeURIComponent(props.pluginId)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((row) => {
|
||||
if (cancelled) return;
|
||||
setPlugin(row as InstalledPluginRecord);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError((err as Error).message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.pluginId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="plugin-detail" data-testid="plugin-detail">
|
||||
<button type="button" onClick={() => navigate({ kind: 'marketplace' })}>
|
||||
← Marketplace
|
||||
</button>
|
||||
<div role="alert">Failed to load plugin: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="plugin-detail" data-testid="plugin-detail">
|
||||
<div>Loading plugin…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const od = plugin.manifest?.od ?? {};
|
||||
const surfaces = od.genui?.surfaces ?? [];
|
||||
const required = od.connectors?.required ?? [];
|
||||
const optional = od.connectors?.optional ?? [];
|
||||
const capabilities = od.capabilities ?? [];
|
||||
|
||||
const onUse = async () => {
|
||||
setApplying(true);
|
||||
setError(null);
|
||||
const result = await applyPlugin(plugin.id);
|
||||
setApplying(false);
|
||||
if (!result) {
|
||||
setError('Apply failed. Make sure the daemon is reachable.');
|
||||
return;
|
||||
}
|
||||
setApplied(result);
|
||||
// Navigate to Home so the existing inline rail / NewProjectPanel
|
||||
// surfaces the applied snapshot. Phase 2B ChatComposer mount then
|
||||
// picks it up automatically inside an existing project.
|
||||
navigate({ kind: 'home' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plugin-detail" data-testid="plugin-detail">
|
||||
<button
|
||||
type="button"
|
||||
className="plugin-detail__back"
|
||||
onClick={() => navigate({ kind: 'marketplace' })}
|
||||
>
|
||||
← Marketplace
|
||||
</button>
|
||||
|
||||
<header className="plugin-detail__header">
|
||||
<h1>{plugin.title}</h1>
|
||||
<div className="plugin-detail__meta">
|
||||
<span>v{plugin.version}</span>
|
||||
<span>trust: {plugin.trust}</span>
|
||||
<span>source: {plugin.sourceKind}</span>
|
||||
{od.taskKind ? <span>{od.taskKind}</span> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{plugin.manifest?.description ? (
|
||||
<p className="plugin-detail__description">{plugin.manifest.description}</p>
|
||||
) : null}
|
||||
|
||||
<section className="plugin-detail__capabilities">
|
||||
<h2>Capabilities</h2>
|
||||
{capabilities.length === 0 ? (
|
||||
<div>None declared (defaults to <code>prompt:inject</code>).</div>
|
||||
) : (
|
||||
<ul>
|
||||
{capabilities.map((c: string) => (
|
||||
<li key={c}>
|
||||
<code>{c}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{required.length > 0 || optional.length > 0 ? (
|
||||
<section className="plugin-detail__connectors">
|
||||
<h2>Connectors</h2>
|
||||
{required.length > 0 ? (
|
||||
<>
|
||||
<h3>Required</h3>
|
||||
<ul>
|
||||
{required.map((c: { id: string; tools?: string[] }) => (
|
||||
<li key={c.id}>
|
||||
<code>{c.id}</code>
|
||||
{c.tools && c.tools.length > 0 ? ` · ${c.tools.join(', ')}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{optional.length > 0 ? (
|
||||
<>
|
||||
<h3>Optional</h3>
|
||||
<ul>
|
||||
{optional.map((c: { id: string; tools?: string[] }) => (
|
||||
<li key={c.id}>
|
||||
<code>{c.id}</code>
|
||||
{c.tools && c.tools.length > 0 ? ` · ${c.tools.join(', ')}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{surfaces.length > 0 ? (
|
||||
<section className="plugin-detail__surfaces">
|
||||
<h2>This plugin may ask you</h2>
|
||||
<ul>
|
||||
{surfaces.map((s: { id: string; kind: string; persist?: string; prompt?: string }) => (
|
||||
<li key={s.id}>
|
||||
<code>{s.kind}</code> · <code>{s.id}</code>
|
||||
{s.persist ? <> · persists at <code>{s.persist}</code></> : null}
|
||||
{s.prompt ? <> — {s.prompt}</> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="plugin-detail__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="plugin-detail__use"
|
||||
onClick={onUse}
|
||||
disabled={applying}
|
||||
data-testid="plugin-detail-use"
|
||||
>
|
||||
{applying ? 'Applying…' : 'Use this plugin'}
|
||||
</button>
|
||||
{applied ? (
|
||||
<div className="plugin-detail__applied">
|
||||
Applied (snapshot {applied.appliedPlugin.snapshotId.slice(0, 8)}…) —
|
||||
redirected to Home with the brief pre-filled.
|
||||
</div>
|
||||
) : null}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@ import { useEffect, useState } from 'react';
|
|||
|
||||
export type Route =
|
||||
| { kind: 'home' }
|
||||
| { kind: 'project'; projectId: string; fileName: string | null };
|
||||
| { kind: 'project'; projectId: string; fileName: string | null }
|
||||
| { kind: 'marketplace' }
|
||||
| { kind: 'marketplace-detail'; pluginId: string };
|
||||
|
||||
export function parseRoute(pathname: string): Route {
|
||||
const parts = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
|
||||
|
|
@ -23,11 +25,24 @@ export function parseRoute(pathname: string): Route {
|
|||
}
|
||||
return { kind: 'project', projectId, fileName: null };
|
||||
}
|
||||
// Phase 2B / spec §11.6 — marketplace deep UI routes. Two paths:
|
||||
// /marketplace → catalog grid (MarketplaceView)
|
||||
// /marketplace/<pluginId> → detail page (PluginDetailView)
|
||||
// Aliases to /plugins remain reserved for the public site (spec §13);
|
||||
// in-app we keep /marketplace canonical.
|
||||
if (parts[0] === 'marketplace' || parts[0] === 'plugins') {
|
||||
if (parts[1]) {
|
||||
return { kind: 'marketplace-detail', pluginId: decodeURIComponent(parts[1]) };
|
||||
}
|
||||
return { kind: 'marketplace' };
|
||||
}
|
||||
return { kind: 'home' };
|
||||
}
|
||||
|
||||
export function buildPath(route: Route): string {
|
||||
if (route.kind === 'home') return '/';
|
||||
if (route.kind === 'marketplace') return '/marketplace';
|
||||
if (route.kind === 'marketplace-detail') return `/marketplace/${encodeURIComponent(route.pluginId)}`;
|
||||
const id = encodeURIComponent(route.projectId);
|
||||
if (route.fileName) {
|
||||
const file = route.fileName
|
||||
|
|
|
|||
69
apps/web/tests/components/MarketplaceView.test.tsx
Normal file
69
apps/web/tests/components/MarketplaceView.test.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
// Plan G4 — MarketplaceView jsdom smoke.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MarketplaceView } from '../../src/components/MarketplaceView';
|
||||
|
||||
const PLUGIN_ROW = {
|
||||
id: 'sample-plugin',
|
||||
title: 'Sample Plugin',
|
||||
version: '1.0.0',
|
||||
trust: 'restricted' as const,
|
||||
sourceKind: 'local' as const,
|
||||
source: '/tmp/sample',
|
||||
manifest: { description: 'A fixture' },
|
||||
};
|
||||
|
||||
const MARKETPLACE_ROW = {
|
||||
id: 'mp-1',
|
||||
url: 'https://example.com/marketplace.json',
|
||||
trust: 'restricted',
|
||||
manifest: {
|
||||
name: 'Example marketplace',
|
||||
plugins: [{ name: 'sample-plugin', source: 'github:open-design/sample-plugin' }],
|
||||
},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async (url) => {
|
||||
if (url === '/api/plugins') {
|
||||
return new Response(JSON.stringify({ plugins: [PLUGIN_ROW] }), { status: 200 });
|
||||
}
|
||||
if (url === '/api/marketplaces') {
|
||||
return new Response(JSON.stringify({ marketplaces: [MARKETPLACE_ROW] }), { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('MarketplaceView', () => {
|
||||
it('renders the installed plugins as cards and the configured catalogs', async () => {
|
||||
render(<MarketplaceView />);
|
||||
await waitFor(() => screen.getByTestId('marketplace-grid'));
|
||||
expect(screen.getByText('Sample Plugin')).toBeTruthy();
|
||||
expect(screen.getByText('A fixture')).toBeTruthy();
|
||||
expect(screen.getByText('Example marketplace')).toBeTruthy();
|
||||
expect(screen.getByText(/1 plugin\(s\)/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('filters by trust tier when the user clicks Trusted', async () => {
|
||||
render(<MarketplaceView />);
|
||||
await waitFor(() => screen.getByText('Sample Plugin'));
|
||||
const trustedFilter = screen.getByText('Trusted', { selector: 'button' });
|
||||
trustedFilter.click();
|
||||
await waitFor(() => expect(screen.queryByText('Sample Plugin')).toBeNull());
|
||||
expect(
|
||||
screen.getByText(/No plugins installed yet|No plugins/, { exact: false }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
39
apps/web/tests/router-marketplace.test.ts
Normal file
39
apps/web/tests/router-marketplace.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Plan G4 — Phase 2B router contract for the marketplace routes.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPath, parseRoute, type Route } from '../src/router';
|
||||
|
||||
describe('router /marketplace', () => {
|
||||
it('parses /marketplace as the catalog grid route', () => {
|
||||
expect(parseRoute('/marketplace')).toEqual({ kind: 'marketplace' });
|
||||
expect(parseRoute('/marketplace/')).toEqual({ kind: 'marketplace' });
|
||||
});
|
||||
|
||||
it('parses /marketplace/<pluginId> as a detail route', () => {
|
||||
expect(parseRoute('/marketplace/sample-plugin')).toEqual({
|
||||
kind: 'marketplace-detail',
|
||||
pluginId: 'sample-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses /plugins/<pluginId> as the same detail route (alias)', () => {
|
||||
expect(parseRoute('/plugins/sample-plugin')).toEqual({
|
||||
kind: 'marketplace-detail',
|
||||
pluginId: 'sample-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips through buildPath', () => {
|
||||
for (const route of [
|
||||
{ kind: 'marketplace' } as Route,
|
||||
{ kind: 'marketplace-detail', pluginId: 'sample-plugin' } as Route,
|
||||
]) {
|
||||
expect(parseRoute(buildPath(route))).toEqual(route);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not break the home / project routes', () => {
|
||||
expect(parseRoute('/')).toEqual({ kind: 'home' });
|
||||
expect(parseRoute('/projects/abc')).toEqual({ kind: 'project', projectId: 'abc', fileName: null });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue