feat(10-01): add SkillCard component, tests, route wiring, and design guide entry
- Create SkillCard.tsx with 3-row layout per UI-SPEC (name link, description, source/rating/actions) - Create SkillCard.test.tsx with 8 passing unit tests using renderToStaticMarkup - Create SkillBrowser.tsx and SkillDetail.tsx stub pages - Update App.tsx: remove CompanySkills import, add skills and skills/detail/:skillId routes - Add SkillCard to DesignGuide.tsx with 4 variants (default, installed, update available, loading)
This commit is contained in:
parent
2616f0e3fd
commit
eabddd3d9a
6 changed files with 286 additions and 2 deletions
|
|
@ -26,7 +26,8 @@ import { Costs } from "./pages/Costs";
|
|||
import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { SkillBrowser } from "./pages/SkillBrowser";
|
||||
import { SkillDetail } from "./pages/SkillDetail";
|
||||
import { CompanyExport } from "./pages/CompanyExport";
|
||||
import { CompanyImport } from "./pages/CompanyImport";
|
||||
import { DesignGuide } from "./pages/DesignGuide";
|
||||
|
|
@ -127,7 +128,8 @@ function boardRoutes() {
|
|||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="skills/*" element={<CompanySkills />} />
|
||||
<Route path="skills" element={<SkillBrowser />} />
|
||||
<Route path="skills/detail/:skillId" element={<SkillDetail />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
||||
|
|
|
|||
78
ui/src/components/SkillCard.test.tsx
Normal file
78
ui/src/components/SkillCard.test.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import type { SkillListItem } from "../api/skillRegistry";
|
||||
|
||||
// Stub @/lib/router Link as an <a> tag for SSR
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode; [key: string]: unknown }) =>
|
||||
<a href={to as string} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
const mockSkill: SkillListItem = {
|
||||
id: "test-source/test-skill",
|
||||
name: "Test Skill",
|
||||
description: "A test skill for unit testing",
|
||||
sourceId: "test-source",
|
||||
category: "testing",
|
||||
activeVersionId: null,
|
||||
removedAt: null,
|
||||
averageRating: 4.2,
|
||||
ratingCount: 10,
|
||||
};
|
||||
|
||||
describe("SkillCard", () => {
|
||||
it("renders skill name as a link", () => {
|
||||
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
|
||||
expect(html).toContain("Test Skill");
|
||||
expect(html).toContain("skills/detail/");
|
||||
});
|
||||
|
||||
it("renders source badge", () => {
|
||||
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
|
||||
expect(html).toContain("test-source");
|
||||
});
|
||||
|
||||
it("renders star rating when averageRating is non-null", () => {
|
||||
const html = renderToStaticMarkup(<SkillCard skill={mockSkill} />);
|
||||
expect(html).toContain("4.2");
|
||||
});
|
||||
|
||||
it("shows Install skill button when not installed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SkillCard skill={mockSkill} onInstall={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("Install skill");
|
||||
});
|
||||
|
||||
it("shows Update skill button when installed with update", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SkillCard skill={mockSkill} isInstalled hasUpdate onUpdate={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("Update skill");
|
||||
});
|
||||
|
||||
it("shows update badge when hasUpdate is true", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SkillCard skill={mockSkill} isInstalled hasUpdate onUpdate={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("Update");
|
||||
expect(html).toContain("amber");
|
||||
});
|
||||
|
||||
it("shows check icon when installed without update", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SkillCard skill={mockSkill} isInstalled />,
|
||||
);
|
||||
expect(html).toContain("Installed");
|
||||
});
|
||||
|
||||
it("shows loading state on install button", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SkillCard skill={mockSkill} onInstall={() => {}} isLoading />,
|
||||
);
|
||||
expect(html).toContain("Installing");
|
||||
});
|
||||
});
|
||||
127
ui/src/components/SkillCard.tsx
Normal file
127
ui/src/components/SkillCard.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Check, Download, RotateCcw, Star } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { SkillListItem } from "@/api/skillRegistry";
|
||||
|
||||
// TODO: hasUpdate detection requires backend enhancement — SkillListItem needs
|
||||
// a hasUpdate field or the UI needs to compare activeVersionId against latest version.
|
||||
// For now, hasUpdate is always passed as false from parent components.
|
||||
|
||||
export interface SkillCardProps {
|
||||
skill: SkillListItem;
|
||||
isInstalled?: boolean;
|
||||
hasUpdate?: boolean;
|
||||
onInstall?: () => void;
|
||||
onUpdate?: () => void;
|
||||
onRollback?: () => void;
|
||||
onUninstall?: () => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkillCard({
|
||||
skill,
|
||||
isInstalled = false,
|
||||
hasUpdate = false,
|
||||
onInstall,
|
||||
onUpdate,
|
||||
onRollback,
|
||||
onUninstall,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: SkillCardProps) {
|
||||
return (
|
||||
<Card className={cn("flex flex-col", className)}>
|
||||
<CardContent className="p-4 flex flex-col gap-2">
|
||||
|
||||
{/* Row 1: name link (primary visual anchor) + update badge */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link
|
||||
to={`/skills/detail/${encodeURIComponent(skill.id)}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{skill.name}
|
||||
</Link>
|
||||
{hasUpdate && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-amber-600 border-amber-500 shrink-0"
|
||||
aria-label="Update available"
|
||||
>
|
||||
Update
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: description (2-line clamp) */}
|
||||
{skill.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{skill.description}</p>
|
||||
)}
|
||||
|
||||
{/* Row 3: source badge + rating + actions (push right) */}
|
||||
<div className="flex items-center gap-2 mt-auto pt-2">
|
||||
<Badge variant="secondary" className="text-xs">{skill.sourceId}</Badge>
|
||||
{skill.averageRating != null && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
{skill.averageRating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1">
|
||||
{isInstalled && onRollback && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={onRollback}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rollback</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isInstalled && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onInstall}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 mr-1" />
|
||||
{isLoading ? "Installing\u2026" : "Install skill"}
|
||||
</Button>
|
||||
)}
|
||||
{isInstalled && hasUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onUpdate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Updating\u2026" : "Update skill"}
|
||||
</Button>
|
||||
)}
|
||||
{isInstalled && !hasUpdate && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled
|
||||
title="Installed"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,6 +124,7 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar";
|
|||
import { InlineEditor } from "@/components/InlineEditor";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { SkillCard } from "@/components/SkillCard";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Section wrapper */
|
||||
|
|
@ -1254,6 +1255,76 @@ export function DesignGuide() {
|
|||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* SKILL CARD */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Skill Card">
|
||||
<SubSection title="Variants">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<SkillCard
|
||||
skill={{
|
||||
id: "anthropic/code-review",
|
||||
name: "Code Review",
|
||||
description: "Automated code review with style, security, and correctness checks.",
|
||||
sourceId: "anthropic",
|
||||
category: "engineering",
|
||||
activeVersionId: null,
|
||||
removedAt: null,
|
||||
averageRating: 4.7,
|
||||
ratingCount: 42,
|
||||
}}
|
||||
onInstall={() => {}}
|
||||
/>
|
||||
<SkillCard
|
||||
skill={{
|
||||
id: "anthropic/planning",
|
||||
name: "Planning",
|
||||
description: "Breaks down complex features into actionable tasks.",
|
||||
sourceId: "anthropic",
|
||||
category: "productivity",
|
||||
activeVersionId: "v1.0.0",
|
||||
removedAt: null,
|
||||
averageRating: null,
|
||||
ratingCount: null,
|
||||
}}
|
||||
isInstalled
|
||||
/>
|
||||
<SkillCard
|
||||
skill={{
|
||||
id: "community/docs-writer",
|
||||
name: "Docs Writer",
|
||||
description: "Generates clear technical documentation from code and comments.",
|
||||
sourceId: "community",
|
||||
category: "docs",
|
||||
activeVersionId: "v1.1.0",
|
||||
removedAt: null,
|
||||
averageRating: 3.9,
|
||||
ratingCount: 15,
|
||||
}}
|
||||
isInstalled
|
||||
hasUpdate
|
||||
onUpdate={() => {}}
|
||||
onRollback={() => {}}
|
||||
/>
|
||||
<SkillCard
|
||||
skill={{
|
||||
id: "anthropic/code-review",
|
||||
name: "Code Review",
|
||||
description: "Automated code review with style, security, and correctness checks.",
|
||||
sourceId: "anthropic",
|
||||
category: "engineering",
|
||||
activeVersionId: null,
|
||||
removedAt: null,
|
||||
averageRating: 4.7,
|
||||
ratingCount: 42,
|
||||
}}
|
||||
onInstall={() => {}}
|
||||
isLoading
|
||||
/>
|
||||
</div>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* SEPARATOR */}
|
||||
{/* ============================================================ */}
|
||||
|
|
|
|||
3
ui/src/pages/SkillBrowser.tsx
Normal file
3
ui/src/pages/SkillBrowser.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function SkillBrowser() {
|
||||
return <div>SkillBrowser placeholder</div>;
|
||||
}
|
||||
3
ui/src/pages/SkillDetail.tsx
Normal file
3
ui/src/pages/SkillDetail.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function SkillDetail() {
|
||||
return <div>SkillDetail placeholder</div>;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue