From 336eb68adba941d8323e59ecf9221342cf2c8f46 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sun, 5 Apr 2026 10:25:32 +0200 Subject: [PATCH] feat(web): add stories for all presentational components Add Storybook stories for: - UI primitives: Badge, Input, Textarea, Separator, Skeleton, Avatar - Bug components: StatusBadge, LabelBadge, BugRow - Code components: RefSelector, FileTree, FileViewer, CodeBreadcrumb - Content: Markdown Also add a TanStack Router decorator for components using . Co-Authored-By: Claude Opus 4.6 (1M context) --- webui2/.storybook/decorators.tsx | 26 +++++ webui2/src/components/bugs/BugRow.stories.tsx | 74 ++++++++++++ .../components/bugs/LabelBadge.stories.tsx | 54 +++++++++ .../components/bugs/StatusBadge.stories.tsx | 26 +++++ .../code/CodeBreadcrumb.stories.tsx | 40 +++++++ .../src/components/code/FileTree.stories.tsx | 107 ++++++++++++++++++ .../components/code/FileViewer.stories.tsx | 72 ++++++++++++ .../components/code/RefSelector.stories.tsx | 47 ++++++++ .../components/content/Markdown.stories.tsx | 95 ++++++++++++++++ webui2/src/components/ui/avatar.stories.tsx | 45 ++++++++ webui2/src/components/ui/badge.stories.tsx | 32 ++++++ webui2/src/components/ui/input.stories.tsx | 26 +++++ .../src/components/ui/separator.stories.tsx | 42 +++++++ webui2/src/components/ui/skeleton.stories.tsx | 30 +++++ webui2/src/components/ui/textarea.stories.tsx | 22 ++++ 15 files changed, 738 insertions(+) create mode 100644 webui2/.storybook/decorators.tsx create mode 100644 webui2/src/components/bugs/BugRow.stories.tsx create mode 100644 webui2/src/components/bugs/LabelBadge.stories.tsx create mode 100644 webui2/src/components/bugs/StatusBadge.stories.tsx create mode 100644 webui2/src/components/code/CodeBreadcrumb.stories.tsx create mode 100644 webui2/src/components/code/FileTree.stories.tsx create mode 100644 webui2/src/components/code/FileViewer.stories.tsx create mode 100644 webui2/src/components/code/RefSelector.stories.tsx create mode 100644 webui2/src/components/content/Markdown.stories.tsx create mode 100644 webui2/src/components/ui/avatar.stories.tsx create mode 100644 webui2/src/components/ui/badge.stories.tsx create mode 100644 webui2/src/components/ui/input.stories.tsx create mode 100644 webui2/src/components/ui/separator.stories.tsx create mode 100644 webui2/src/components/ui/skeleton.stories.tsx create mode 100644 webui2/src/components/ui/textarea.stories.tsx diff --git a/webui2/.storybook/decorators.tsx b/webui2/.storybook/decorators.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04a23bf435d2fad66f0bb0f7fa32792f78bb2ea4 --- /dev/null +++ b/webui2/.storybook/decorators.tsx @@ -0,0 +1,26 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import type { Decorator } from "@storybook/react-vite"; + +// Catch-all route so any resolves without errors. +const rootRoute = createRootRoute(); +const catchAll = createRoute({ + getParentRoute: () => rootRoute, + path: "$", +}); +rootRoute.addChildren([catchAll]); + +const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ["/"] }), +}); + +// Wraps a story in a TanStack Router context so components using render. +export const withRouter: Decorator = (Story) => ( + } /> +); diff --git a/webui2/src/components/bugs/BugRow.stories.tsx b/webui2/src/components/bugs/BugRow.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..99c9beb9917d7050155e97923e30a11983aeb65a --- /dev/null +++ b/webui2/src/components/bugs/BugRow.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Status } from "@/__generated__/graphql"; +import { withRouter } from "@/../.storybook/decorators"; + +import { BugRow } from "./BugRow"; + +const meta = { + component: BugRow, + decorators: [withRouter], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const baseArgs = { + id: "abc123", + humanId: "a1b2c3", + repo: "my-repo", + author: { + humanId: "user1", + displayName: "Jane Doe", + avatarUrl: null, + }, + createdAt: new Date(Date.now() - 3600 * 1000).toISOString(), +}; + +export const OpenBug: Story = { + args: { + ...baseArgs, + status: Status.Open, + title: "Fix login page crash on empty email", + labels: [ + { name: "bug", color: { R: 252, G: 41, B: 41 } }, + { name: "priority", color: { R: 255, G: 152, B: 0 } }, + ], + commentCount: 3, + }, +}; + +export const ClosedBug: Story = { + args: { + ...baseArgs, + status: Status.Closed, + title: "Add dark mode support", + labels: [{ name: "enhancement", color: { R: 163, G: 230, B: 53 } }], + commentCount: 12, + }, +}; + +export const NoLabels: Story = { + args: { + ...baseArgs, + status: Status.Open, + title: "Simple bug with no labels", + labels: [], + commentCount: 0, + }, +}; + +export const LongTitle: Story = { + args: { + ...baseArgs, + status: Status.Open, + title: + "This is a very long bug title that should demonstrate how the component handles overflow and wrapping when the title extends beyond the available space", + labels: [ + { name: "bug", color: { R: 252, G: 41, B: 41 } }, + { name: "documentation", color: { R: 30, G: 80, B: 160 } }, + { name: "help wanted", color: { R: 0, G: 150, B: 136 } }, + ], + commentCount: 42, + }, +}; diff --git a/webui2/src/components/bugs/LabelBadge.stories.tsx b/webui2/src/components/bugs/LabelBadge.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48c73811230c34401697841f7e5b913f9f251cfe --- /dev/null +++ b/webui2/src/components/bugs/LabelBadge.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; + +import { LabelBadge } from "./LabelBadge"; + +const meta = { + component: LabelBadge, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + name: "bug", + color: { R: 252, G: 41, B: 41 }, + }, +}; + +export const LightBackground: Story = { + args: { + name: "enhancement", + color: { R: 163, G: 230, B: 53 }, + }, +}; + +export const DarkBackground: Story = { + args: { + name: "documentation", + color: { R: 30, G: 80, B: 160 }, + }, +}; + +export const Clickable: Story = { + args: { + name: "feature", + color: { R: 100, G: 200, B: 150 }, + onClick: fn(), + }, +}; + +export const AllColors: Story = { + args: { name: "", color: { R: 0, G: 0, B: 0 } }, + render: () => ( +
+ + + + + + +
+ ), +}; diff --git a/webui2/src/components/bugs/StatusBadge.stories.tsx b/webui2/src/components/bugs/StatusBadge.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d76b8fd9acf9437b85befb9f362fa99176004de --- /dev/null +++ b/webui2/src/components/bugs/StatusBadge.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Status } from "@/__generated__/graphql"; + +import { StatusBadge } from "./StatusBadge"; + +const meta = { + component: StatusBadge, + argTypes: { + status: { + control: "select", + options: [Status.Open, Status.Closed], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Open: Story = { + args: { status: Status.Open }, +}; + +export const Closed: Story = { + args: { status: Status.Closed }, +}; diff --git a/webui2/src/components/code/CodeBreadcrumb.stories.tsx b/webui2/src/components/code/CodeBreadcrumb.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6569a6f3ea44f0cee560030218aa30af01317daa --- /dev/null +++ b/webui2/src/components/code/CodeBreadcrumb.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { withRouter } from "@/../.storybook/decorators"; + +import { CodeBreadcrumb } from "./CodeBreadcrumb"; + +const meta = { + component: CodeBreadcrumb, + decorators: [withRouter], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const RootPath: Story = { + args: { + repoName: "git-bug", + currentRef: "main", + path: "", + repo: "git-bug", + }, +}; + +export const FilePath: Story = { + args: { + repoName: "git-bug", + currentRef: "main", + path: "src/components/ui/button.tsx", + repo: "git-bug", + }, +}; + +export const DeepPath: Story = { + args: { + repoName: "git-bug", + currentRef: "feature/auth", + path: "src/components/bugs/timeline/CommentItem.tsx", + repo: "git-bug", + }, +}; diff --git a/webui2/src/components/code/FileTree.stories.tsx b/webui2/src/components/code/FileTree.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f532ef2001c91c0582f31faa2b7487f81fa852ec --- /dev/null +++ b/webui2/src/components/code/FileTree.stories.tsx @@ -0,0 +1,107 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { GitObjectType } from "@/__generated__/graphql"; +import { withRouter } from "@/../.storybook/decorators"; + +import { FileTree } from "./FileTree"; + +const meta = { + component: FileTree, + decorators: [withRouter], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const entries = [ + { + name: "src", + type: GitObjectType.Tree, + hash: "abc1", + lastCommit: { + hash: "def1", + shortHash: "def1", + message: "refactor: restructure source directory", + date: new Date(Date.now() - 86400 * 1000).toISOString(), + }, + }, + { + name: "docs", + type: GitObjectType.Tree, + hash: "abc2", + lastCommit: { + hash: "def2", + shortHash: "def2", + message: "docs: update getting started guide", + date: new Date(Date.now() - 172800 * 1000).toISOString(), + }, + }, + { + name: "README.md", + type: GitObjectType.Blob, + hash: "abc3", + lastCommit: { + hash: "def3", + shortHash: "def3", + message: "docs: add badges to README", + date: new Date(Date.now() - 3600 * 1000).toISOString(), + }, + }, + { + name: "package.json", + type: GitObjectType.Blob, + hash: "abc4", + lastCommit: { + hash: "def4", + shortHash: "def4", + message: "chore: bump dependencies", + date: new Date(Date.now() - 7200 * 1000).toISOString(), + }, + }, + { + name: ".gitignore", + type: GitObjectType.Blob, + hash: "abc5", + }, +]; + +export const RootDirectory: Story = { + args: { + repo: "my-repo", + currentRef: "main", + currentPath: "", + entries, + }, +}; + +export const SubDirectory: Story = { + args: { + repo: "my-repo", + currentRef: "main", + currentPath: "src", + entries: [ + { + name: "components", + type: GitObjectType.Tree, + hash: "abc6", + lastCommit: { + hash: "def5", + shortHash: "def5", + message: "feat: add button component", + date: new Date(Date.now() - 3600 * 1000).toISOString(), + }, + }, + { + name: "index.ts", + type: GitObjectType.Blob, + hash: "abc7", + lastCommit: { + hash: "def6", + shortHash: "def6", + message: "fix: correct export paths", + date: new Date(Date.now() - 7200 * 1000).toISOString(), + }, + }, + ], + }, +}; diff --git a/webui2/src/components/code/FileViewer.stories.tsx b/webui2/src/components/code/FileViewer.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ee425e8b67416784923d2feb50245165a87c994 --- /dev/null +++ b/webui2/src/components/code/FileViewer.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { FileViewer } from "./FileViewer"; + +const meta = { + component: FileViewer, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TypeScriptFile: Story = { + args: { + blob: { + text: `import { useState } from "react"; + +interface CounterProps { + initial?: number; +} + +export function Counter({ initial = 0 }: CounterProps) { + const [count, setCount] = useState(initial); + + return ( +
+

Count: {count}

+ +
+ ); +}`, + hash: "abc123", + path: "src/Counter.tsx", + size: 312, + isBinary: false, + isTruncated: false, + }, + }, +}; + +export const BinaryFile: Story = { + args: { + blob: { + text: null, + hash: "def456", + path: "logo.png", + size: 24576, + isBinary: true, + isTruncated: false, + }, + }, +}; + +export const TruncatedFile: Story = { + args: { + blob: { + text: "line 1\nline 2\nline 3\n... (truncated)", + hash: "ghi789", + path: "large-file.log", + size: 1048576, + isBinary: false, + isTruncated: true, + }, + }, +}; + +export const Loading: Story = { + args: { + blob: null, + }, +}; diff --git a/webui2/src/components/code/RefSelector.stories.tsx b/webui2/src/components/code/RefSelector.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b3e9a1c66f7423e8aab87c9cc4704ca9a7876041 --- /dev/null +++ b/webui2/src/components/code/RefSelector.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; + +import { GitRefType } from "@/__generated__/graphql"; + +import { RefSelector } from "./RefSelector"; + +const meta = { + component: RefSelector, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sampleRefs = [ + { name: "refs/heads/main", shortName: "main", type: GitRefType.Branch, hash: "abc1", isDefault: true }, + { name: "refs/heads/develop", shortName: "develop", type: GitRefType.Branch, hash: "abc2", isDefault: false }, + { name: "refs/heads/feature/auth", shortName: "feature/auth", type: GitRefType.Branch, hash: "abc3", isDefault: false }, + { name: "refs/heads/fix/login", shortName: "fix/login", type: GitRefType.Branch, hash: "abc4", isDefault: false }, + { name: "refs/tags/v1.0.0", shortName: "v1.0.0", type: GitRefType.Tag, hash: "abc5", isDefault: false }, + { name: "refs/tags/v1.1.0", shortName: "v1.1.0", type: GitRefType.Tag, hash: "abc6", isDefault: false }, + { name: "refs/tags/v2.0.0-rc1", shortName: "v2.0.0-rc1", type: GitRefType.Tag, hash: "abc7", isDefault: false }, +]; + +export const Default: Story = { + args: { + refs: sampleRefs, + currentRef: "main", + onSelect: fn(), + }, +}; + +export const OnTag: Story = { + args: { + refs: sampleRefs, + currentRef: "v1.1.0", + onSelect: fn(), + }, +}; + +export const BranchesOnly: Story = { + args: { + refs: sampleRefs.filter((r) => r.type === GitRefType.Branch), + currentRef: "develop", + onSelect: fn(), + }, +}; diff --git a/webui2/src/components/content/Markdown.stories.tsx b/webui2/src/components/content/Markdown.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c79b85cb4e315121549ac050cd2f4761ef966cc5 --- /dev/null +++ b/webui2/src/components/content/Markdown.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { withRouter } from "@/../.storybook/decorators"; + +import { Markdown } from "./Markdown"; + +const meta = { + component: Markdown, + decorators: [withRouter], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BasicFormatting: Story = { + args: { + content: `# Heading 1 +## Heading 2 +### Heading 3 + +This is a paragraph with **bold**, *italic*, and \`inline code\`. + +- Unordered list item 1 +- Unordered list item 2 + - Nested item + +1. Ordered list item 1 +2. Ordered list item 2 + +> This is a blockquote. + +--- + +[A link](https://example.com)`, + }, +}; + +export const CodeBlock: Story = { + args: { + content: `Here is a code block: + +\`\`\`typescript +interface User { + id: string; + name: string; + email: string; +} + +function greet(user: User): string { + return \`Hello, \${user.name}!\`; +} +\`\`\``, + }, +}; + +export const GithubFlavored: Story = { + args: { + content: `## Task list + +- [x] Completed task +- [ ] Incomplete task +- [ ] Another task + +## Table + +| Feature | Status | Priority | +|---------|--------|----------| +| Auth | Done | High | +| Search | WIP | Medium | +| Export | Todo | Low | + +## Strikethrough + +This is ~~deleted~~ text. + +## Emoji + +:rocket: :bug: :sparkles:`, + }, +}; + +export const Comment: Story = { + args: { + content: `Fixed the issue by updating the query builder. + +The problem was that \`buildQuery()\` wasn't escaping special characters in label names containing spaces. + +\`\`\`diff +- const q = \`label:\${name}\`; ++ const q = \`label:"\${name}"\`; +\`\`\` + +Closes #42.`, + }, +}; diff --git a/webui2/src/components/ui/avatar.stories.tsx b/webui2/src/components/ui/avatar.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4417043c71a6b43e16f73bde814e33adfafe2e36 --- /dev/null +++ b/webui2/src/components/ui/avatar.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; + +const meta = { + component: Avatar, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithImage: Story = { + render: () => ( + + + CN + + ), +}; + +export const WithFallback: Story = { + render: () => ( + + + JD + + ), +}; + +export const Small: Story = { + render: () => ( + + AB + + ), +}; + +export const Large: Story = { + render: () => ( + + + CN + + ), +}; diff --git a/webui2/src/components/ui/badge.stories.tsx b/webui2/src/components/ui/badge.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..10ade318ffbee3565068f5e75639806ee08d8530 --- /dev/null +++ b/webui2/src/components/ui/badge.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Badge } from "./badge"; + +const meta = { + component: Badge, + argTypes: { + variant: { + control: "select", + options: ["default", "secondary", "destructive", "outline"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { children: "Badge" }, +}; + +export const Secondary: Story = { + args: { children: "Secondary", variant: "secondary" }, +}; + +export const Destructive: Story = { + args: { children: "Destructive", variant: "destructive" }, +}; + +export const Outline: Story = { + args: { children: "Outline", variant: "outline" }, +}; diff --git a/webui2/src/components/ui/input.stories.tsx b/webui2/src/components/ui/input.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d65da29982cbac4f144a7195764ad89bfa620161 --- /dev/null +++ b/webui2/src/components/ui/input.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Input } from "./input"; + +const meta = { + component: Input, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Type something…" }, +}; + +export const WithValue: Story = { + args: { defaultValue: "Hello world" }, +}; + +export const Disabled: Story = { + args: { placeholder: "Disabled", disabled: true }, +}; + +export const Password: Story = { + args: { type: "password", placeholder: "Enter password" }, +}; diff --git a/webui2/src/components/ui/separator.stories.tsx b/webui2/src/components/ui/separator.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a55d4bd08ef867efcf79769ae1938e2ae60f6adc --- /dev/null +++ b/webui2/src/components/ui/separator.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Separator } from "./separator"; + +const meta = { + component: Separator, + argTypes: { + orientation: { + control: "select", + options: ["horizontal", "vertical"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Horizontal: Story = { + args: { orientation: "horizontal" }, + decorators: [ + (Story) => ( +
+

Above

+ +

Below

+
+ ), + ], +}; + +export const Vertical: Story = { + args: { orientation: "vertical" }, + decorators: [ + (Story) => ( +
+ Left + + Right +
+ ), + ], +}; diff --git a/webui2/src/components/ui/skeleton.stories.tsx b/webui2/src/components/ui/skeleton.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee84b198a52e84d061760d6f23012a8e6dc1b351 --- /dev/null +++ b/webui2/src/components/ui/skeleton.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Skeleton } from "./skeleton"; + +const meta = { + component: Skeleton, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { className: "h-4 w-48" }, +}; + +export const Circle: Story = { + args: { className: "size-10 rounded-full" }, +}; + +export const Card: Story = { + render: () => ( +
+ +
+ + +
+
+ ), +}; diff --git a/webui2/src/components/ui/textarea.stories.tsx b/webui2/src/components/ui/textarea.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..00d3f272ea3959dff1928c8dcf966ab6b055aa27 --- /dev/null +++ b/webui2/src/components/ui/textarea.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Textarea } from "./textarea"; + +const meta = { + component: Textarea, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Write a comment…" }, +}; + +export const WithValue: Story = { + args: { defaultValue: "This is some content in the textarea." }, +}; + +export const Disabled: Story = { + args: { placeholder: "Disabled", disabled: true }, +};