Dynamic per-org skill library: in-app authoring, versioning, fork (+ marketplace seam)
Skills move from a global Git-only registry to a per-company library that orgs author and
version in-app — Git stays as the shared *starter* library.
Domain & persistence:
- Skill gains OrganizationId (null = shared builtin, visible to every org), Origin
(Builtin | Authored | Installed), AuthoredByMemberId. Identity is now
(OrganizationId, SkillKey, Version); the unique index uses NULLS NOT DISTINCT so builtins
stay unique by key+version while each org gets its own namespace (and can fork a builtin).
AddSkillOwnership migration backfills existing rows as Builtin.
- Owned GoldenExample rows are cloned in Skill.Index so a fork can't re-parent the source's
tracked entities.
Authoring (tenant, dynamic):
- POST /api/skills/authored — structured fields → same indexer pipeline (embedding +
publish gate apply identically), tagged org + author. POST /api/skills/{key}/fork copies a
builtin/global skill into your org as an editable Authored draft. List/Get are org-scoped
(your org + shared builtins). New Capability.ManageSkills (Owner + TeamOwner), audited.
- GET /api/skills/marketplace: read-only seam listing public skills across orgs (install is
the next step).
Security (from adversarial review — two confirmed criticals):
- Managing shared builtins is an operator action, not a tenant one. /index (posts arbitrary
content as a global builtin) and /sync (re-indexes the shared library) now require a
platform admin key (X-Skills-Admin-Key, fixed-time compare, fail-closed when unset) via
SkillAdminOptions — previously any authenticated user of any org could inject/poison global
skills. New test asserts an authenticated Owner without the key gets 403 on both.
UI: new /skills library page — browse shared + org skills grouped by key with their versions,
create / new-version / fork, golden-test editor + body, Draft/Published badge and the
publish-gate hint (needs roles + ≥1 golden test).
Verified: ArchitectureTests 8/8, IntegrationTests 46/46 (new SkillLibraryTests: org
isolation, version coexistence, fork, publish gate, Member 403, admin-gate 403), client build
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage'
|
||||
import { PerformancePage } from '@/pages/PerformancePage'
|
||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||
import { SeatsPage } from '@/pages/SeatsPage'
|
||||
import { SkillsPage } from '@/pages/SkillsPage'
|
||||
import { StructurePage } from '@/pages/StructurePage'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/skills" element={token ? <SkillsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
BookMarked,
|
||||
Bot,
|
||||
Boxes,
|
||||
ChartColumn,
|
||||
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
|
||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||
<NavItem icon={BookMarked} label="Skills" to="/skills" />
|
||||
<NavItem icon={Network} label="Org chart" to="/org" />
|
||||
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
||||
<NavItem icon={Users} label="Members" to="/members" />
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
interface ActionDto {
|
||||
name: string
|
||||
risk: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface GoldenTest {
|
||||
input: string
|
||||
expected: string
|
||||
}
|
||||
|
||||
interface SkillSummary {
|
||||
skillKey: string
|
||||
name: string
|
||||
version: string
|
||||
summary: string | null
|
||||
roles: string[]
|
||||
visibility: string
|
||||
minTier: string
|
||||
status: string
|
||||
origin: string
|
||||
organizationId: string | null
|
||||
goldenTestCount: number
|
||||
actions: ActionDto[]
|
||||
}
|
||||
|
||||
interface SkillDetail {
|
||||
skill: SkillSummary
|
||||
inputs: string | null
|
||||
outputs: string | null
|
||||
tools: string[]
|
||||
context: string[]
|
||||
goldenTests: GoldenTest[]
|
||||
body: string
|
||||
}
|
||||
|
||||
type Mode = 'new' | 'version' | 'edit'
|
||||
|
||||
interface FormState {
|
||||
mode: Mode
|
||||
skillKey: string
|
||||
name: string
|
||||
version: string
|
||||
summary: string
|
||||
roles: string
|
||||
inputs: string
|
||||
outputs: string
|
||||
tools: string
|
||||
context: string
|
||||
visibility: string
|
||||
minTier: string
|
||||
body: string
|
||||
actions: ActionDto[]
|
||||
goldenTests: GoldenTest[]
|
||||
}
|
||||
|
||||
const COMMON_ROLES = 'product-owner, engineer, qa, designer, analyst'
|
||||
const RISKS = ['read', 'draft', 'publish', 'destructive']
|
||||
const VISIBILITIES = ['public', 'private']
|
||||
const TIERS = ['free', 'team', 'scale', 'enterprise']
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
mode: 'new',
|
||||
skillKey: '',
|
||||
name: '',
|
||||
version: '1.0.0',
|
||||
summary: '',
|
||||
roles: '',
|
||||
inputs: '',
|
||||
outputs: '',
|
||||
tools: '',
|
||||
context: '',
|
||||
visibility: 'public',
|
||||
minTier: 'free',
|
||||
body: '',
|
||||
actions: [],
|
||||
goldenTests: [],
|
||||
})
|
||||
|
||||
/** Bump the last numeric segment of a semver-ish string (1.0.0 → 1.0.1). */
|
||||
function bumpPatch(version: string): string {
|
||||
const parts = version.split('.')
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const n = Number(parts[i])
|
||||
if (Number.isInteger(n)) {
|
||||
parts[i] = String(n + 1)
|
||||
return parts.join('.')
|
||||
}
|
||||
}
|
||||
return `${version}.1`
|
||||
}
|
||||
|
||||
const csv = (s: string): string[] =>
|
||||
s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
|
||||
/** The org's skill library: builtin starter skills + skills the company authors and versions itself. */
|
||||
export function SkillsPage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||
const [marketplace, setMarketplace] = useState<SkillSummary[]>([])
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!organizationId) return
|
||||
try {
|
||||
const [lib, market] = await Promise.all([
|
||||
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
|
||||
api.get<SkillSummary[]>(`/api/skills/marketplace`),
|
||||
])
|
||||
setSkills(lib)
|
||||
setMarketplace(market)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Group every version under its key; newest (and the org's own) first comes from the API order.
|
||||
const groups = useMemo(() => {
|
||||
const byKey = new Map<string, SkillSummary[]>()
|
||||
for (const s of skills) {
|
||||
const list = byKey.get(s.skillKey) ?? []
|
||||
list.push(s)
|
||||
byKey.set(s.skillKey, list)
|
||||
}
|
||||
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
}, [skills])
|
||||
|
||||
const openForm = async (key: string, version: string, mode: Mode) => {
|
||||
try {
|
||||
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
|
||||
const d = details.find((x) => x.skill.version === version) ?? details[0]
|
||||
if (!d) return
|
||||
setForm({
|
||||
mode,
|
||||
skillKey: d.skill.skillKey,
|
||||
name: d.skill.name,
|
||||
version: mode === 'version' ? bumpPatch(d.skill.version) : d.skill.version,
|
||||
summary: d.skill.summary ?? '',
|
||||
roles: d.skill.roles.join(', '),
|
||||
inputs: d.inputs ?? '',
|
||||
outputs: d.outputs ?? '',
|
||||
tools: d.tools.join(', '),
|
||||
context: d.context.join(', '),
|
||||
visibility: d.skill.visibility === 'PrivateToOrg' ? 'private' : 'public',
|
||||
minTier: d.skill.minTier.toLowerCase(),
|
||||
body: d.body,
|
||||
actions: d.skill.actions.map((a) => ({ name: a.name, risk: a.risk.toLowerCase(), description: a.description ?? '' })),
|
||||
goldenTests: d.goldenTests.map((g) => ({ ...g })),
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const fork = async (key: string, version: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post(`/api/skills/${key}/fork`, { organizationId, version })
|
||||
toast.success(`Forked ${key} into your org — edit it to customize.`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post('/api/skills/authored', {
|
||||
organizationId,
|
||||
skillKey: form.skillKey.trim(),
|
||||
name: form.name.trim(),
|
||||
version: form.version.trim(),
|
||||
summary: form.summary.trim() || null,
|
||||
roles: csv(form.roles),
|
||||
inputs: form.inputs.trim() || null,
|
||||
outputs: form.outputs.trim() || null,
|
||||
tools: csv(form.tools),
|
||||
context: csv(form.context),
|
||||
visibility: form.visibility,
|
||||
minTier: form.minTier,
|
||||
body: form.body,
|
||||
actions: form.actions
|
||||
.filter((a) => a.name.trim())
|
||||
.map((a) => ({ name: a.name.trim(), risk: a.risk, description: a.description?.trim() || null })),
|
||||
goldenTests: form.goldenTests.filter((g) => g.input.trim() && g.expected.trim()),
|
||||
})
|
||||
toast.success(`Saved ${form.skillKey}@${form.version}.`)
|
||||
setForm(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const willPublish = !!form && csv(form.roles).length > 0 && form.goldenTests.some((g) => g.input.trim() && g.expected.trim())
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<header className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<BookMarked className="size-6" /> Skills
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your company's skill library. Builtin starter skills are shared; author and version your own.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setForm(emptyForm())}>
|
||||
<Plus data-icon="inline-start" /> New skill
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="mb-4 inline-flex rounded-lg border p-1">
|
||||
<SegBtn active={tab === 'library'} onClick={() => setTab('library')} icon={BookMarked}>Library</SegBtn>
|
||||
<SegBtn active={tab === 'marketplace'} onClick={() => setTab('marketplace')} icon={Store}>Marketplace</SegBtn>
|
||||
</div>
|
||||
|
||||
{tab === 'library' ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.map(([key, versions]) => (
|
||||
<SkillGroupCard
|
||||
key={key}
|
||||
versions={versions}
|
||||
busy={busy}
|
||||
onNewVersion={(v) => openForm(key, v, 'version')}
|
||||
onEdit={(v) => openForm(key, v, 'edit')}
|
||||
onFork={(v) => fork(key, v)}
|
||||
/>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No skills yet. Run a Git sync for builtins, or author one.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Public skills shared by other organizations. One-click install lands in the next step.
|
||||
</p>
|
||||
{marketplace.map((s) => (
|
||||
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{s.name} <Badge variant="outline">{s.version}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{s.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
||||
<Button size="sm" variant="outline" disabled className="ml-auto">Install (soon)</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{marketplace.length === 0 && <p className="text-sm text-muted-foreground">Nothing published yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form && (
|
||||
<Sheet open onOpenChange={(o) => !o && setForm(null)}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{willPublish
|
||||
? 'Has roles + a golden test — saves as Published.'
|
||||
: 'Add ≥1 role and ≥1 golden test to publish; otherwise saved as Draft.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Skill key (id)">
|
||||
<Input
|
||||
value={form.skillKey}
|
||||
disabled={form.mode !== 'new'}
|
||||
onChange={(e) => setForm({ ...form, skillKey: e.target.value })}
|
||||
placeholder="api-endpoint-design"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Version">
|
||||
<Input value={form.version} disabled={form.mode === 'edit'} onChange={(e) => setForm({ ...form, version: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Name">
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" />
|
||||
</Field>
|
||||
<Field label="Summary">
|
||||
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={`Roles (comma-separated — e.g. ${COMMON_ROLES})`}>
|
||||
<Input value={form.roles} onChange={(e) => setForm({ ...form, roles: e.target.value })} placeholder="engineer" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Inputs">
|
||||
<Input value={form.inputs} onChange={(e) => setForm({ ...form, inputs: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Outputs">
|
||||
<Input value={form.outputs} onChange={(e) => setForm({ ...form, outputs: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Visibility">
|
||||
<Pick value={form.visibility} options={VISIBILITIES} onChange={(v) => setForm({ ...form, visibility: v })} />
|
||||
</Field>
|
||||
<Field label="Min tier">
|
||||
<Pick value={form.minTier} options={TIERS} onChange={(v) => setForm({ ...form, minTier: v })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Tools (comma-separated)">
|
||||
<Input value={form.tools} onChange={(e) => setForm({ ...form, tools: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Context docs (comma-separated)">
|
||||
<Input value={form.context} onChange={(e) => setForm({ ...form, context: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Repeater
|
||||
label="Actions (risk-tagged)"
|
||||
onAdd={() => setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })}
|
||||
>
|
||||
{form.actions.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1" placeholder="action name" value={a.name}
|
||||
onChange={(e) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })}
|
||||
/>
|
||||
<Pick
|
||||
value={a.risk} options={RISKS} className="w-32"
|
||||
onChange={(v) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, actions: form.actions.filter((_, j) => j !== i) })}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Repeater>
|
||||
|
||||
<Repeater
|
||||
label="Golden tests (gate publishing)"
|
||||
onAdd={() => setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })}
|
||||
>
|
||||
{form.goldenTests.map((g, i) => (
|
||||
<div key={i} className="flex flex-col gap-2 rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1" placeholder="input" value={g.input}
|
||||
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, goldenTests: form.goldenTests.filter((_, j) => j !== i) })}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
rows={2} placeholder="expected output" value={g.expected}
|
||||
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, expected: e.target.value } : x)) })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Repeater>
|
||||
|
||||
<Field label="Body (the prompt the agent runs)">
|
||||
<Textarea rows={8} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="You are the engineer. Turn the input into…" />
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setForm(null)}>Cancel</Button>
|
||||
<Button disabled={busy || !form.skillKey.trim() || !form.name.trim() || !form.body.trim()} onClick={save}>
|
||||
Save {willPublish ? '& publish' : 'draft'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillGroupCard({
|
||||
versions,
|
||||
busy,
|
||||
onNewVersion,
|
||||
onEdit,
|
||||
onFork,
|
||||
}: {
|
||||
versions: SkillSummary[]
|
||||
busy: boolean
|
||||
onNewVersion: (version: string) => void
|
||||
onEdit: (version: string) => void
|
||||
onFork: (version: string) => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState(versions[0].version)
|
||||
const current = versions.find((v) => v.version === selected) ?? versions[0]
|
||||
const isBuiltin = current.origin === 'Builtin'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{current.name}
|
||||
<span className="font-mono text-xs text-muted-foreground">{current.skillKey}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">{current.summary}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={current.status === 'Published' ? 'default' : 'secondary'}>{current.status}</Badge>
|
||||
<Badge variant="outline">{current.origin}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-2">
|
||||
{versions.length > 1 ? (
|
||||
<Pick value={selected} options={versions.map((v) => v.version)} className="w-28" onChange={setSelected} />
|
||||
) : (
|
||||
<Badge variant="outline">{current.version}</Badge>
|
||||
)}
|
||||
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
||||
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isBuiltin ? (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
|
||||
<GitFork data-icon="inline-start" /> Fork to my org
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
|
||||
<Pencil data-icon="inline-start" /> Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
|
||||
<Plus data-icon="inline-start" /> New version
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SegBtn({ active, onClick, icon: Icon, children }: { active: boolean; onClick: () => void; icon: typeof BookMarked; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition ${active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<Icon className="size-4" /> {children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pick({ value, options, onChange, className }: { value: string; options: string[]; onChange: (v: string) => void; className?: string }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className={className ?? 'w-full'}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{options.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function Repeater({ label, onAdd, children }: { label: string; onAdd: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Button size="sm" variant="ghost" onClick={onAdd}><Plus data-icon="inline-start" /> Add</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user