Detailed changes
@@ -1,3 +1,7 @@
+import { ApolloClient, ApolloLink, Observable } from "@apollo/client/core";
+import { ApolloProvider } from "@apollo/client/react";
+import { InMemoryCache } from "@apollo/client/cache";
+import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
import {
createMemoryHistory,
createRootRoute,
@@ -6,6 +10,7 @@ import {
RouterProvider,
} from "@tanstack/react-router";
import type { Decorator } from "@storybook/react-vite";
+import { Suspense } from "react";
// Catch-all route so any <Link to="..."> resolves without errors.
const rootRoute = createRootRoute();
@@ -24,3 +29,73 @@ const router = createRouter({
export const withRouter: Decorator = (Story) => (
<RouterProvider router={router} defaultComponent={() => <Story />} />
);
+
+// Mock Apollo client for stories.
+// - useSuspenseFragment reads from this cache
+// - useSuspenseQuery for useAuth() hits the mock link
+const mockApolloClient = new ApolloClient({
+ link: new ApolloLink(
+ (operation) => new Observable((observer) => {
+ const data: Record<string, unknown> = {};
+ // Provide mock data for the UserIdentity query used by useAuth()
+ if (operation.operationName === "UserIdentity") {
+ data.repository = {
+ __typename: "Repository",
+ userIdentity: {
+ __typename: "Identity",
+ id: "mock-user",
+ humanId: "mock1",
+ name: "Mock User",
+ displayName: "Mock User",
+ avatarUrl: null,
+ email: null,
+ login: null,
+ },
+ };
+ }
+ observer.next({ data });
+ observer.complete();
+ }),
+ ),
+ cache: new InMemoryCache({
+ typePolicies: {
+ // Types without `id` need explicit keyFields so useSuspenseFragment
+ // can normalize and cache the mock data passed via `from`.
+ Label: { keyFields: ["name"] },
+ GitBlob: { keyFields: ["hash"] },
+ GitRefConnection: { keyFields: [] },
+ BugTimelineItemConnection: { keyFields: [] },
+ },
+ }),
+ dataMasking: false,
+});
+
+// Wraps a story in an ApolloProvider. Components using useSuspenseFragment
+// need their data pre-written to the cache via withCachedFragments().
+export const withApollo: Decorator = (Story) => (
+ <ApolloProvider client={mockApolloClient}>
+ <Suspense fallback={<div style={{ padding: 16, color: "orange" }}>Loadingβ¦</div>}>
+ <Story />
+ </Suspense>
+ </ApolloProvider>
+);
+
+// Pre-writes fragment data into the Apollo cache so that useSuspenseFragment
+// can read it without suspending. Call this in the story's decorators list
+// AFTER withApollo.
+//
+// Usage:
+// decorators: [withApollo, withCachedFragments(
+// [MY_FRAGMENT, "MyFragment", myMockData],
+// [MY_FRAGMENT, "MyFragment", anotherMockData],
+// )]
+export function withCachedFragments(
+ ...entries: Array<[fragment: TypedDocumentNode, fragmentName: string, data: Record<string, unknown>]>
+): Decorator {
+ return (Story) => {
+ for (const [fragment, fragmentName, data] of entries) {
+ mockApolloClient.cache.writeFragment({ fragment, fragmentName, data });
+ }
+ return <Story />;
+ };
+}
@@ -1,5 +1,5 @@
import { setProjectAnnotations } from "@storybook/react-vite";
-import { beforeAll } from "vitest";
+import { beforeAll, vi } from "vitest";
import * as previewAnnotations from "./preview";
@@ -8,3 +8,15 @@ import * as previewAnnotations from "./preview";
const annotations = setProjectAnnotations([previewAnnotations]);
beforeAll(annotations.beforeAll);
+
+// Make useSuspenseFragment a passthrough in happy-dom snapshot tests.
+// The real hook reads from the Apollo cache, which requires data to be
+// written via a query first. In stories we pass mock objects directly,
+// so we just return the `from` data as-is.
+vi.mock("@apollo/client/react", async (importOriginal) => {
+ const mod = await importOriginal<typeof import("@apollo/client/react")>();
+ return {
+ ...mod,
+ useSuspenseFragment: ({ from }: { from: unknown }) => ({ data: from }),
+ };
+});
@@ -0,0 +1,89 @@
+# Component Status Tracker
+
+Status legend: done / partial / todo / n/a
+
+## Shared Components (`src/components/shared/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| comment-card | done (`IdentitySummary`) | done (Root/AuthorAvatar/Card/CardHeader/CardBody) | done | n/a (display only) | done | Uses `withApollo` decorator |
+| empty-state | n/a | n/a (simple wrapper) | done | n/a | done | |
+| issue-filters | n/a (callbacks only) | todo (single export, complex) | done | todo (dropdowns, search, keyboard nav) | todo | Has complex floating-ui interactions, needs `withApollo` |
+| issue-row | done (`BugSummary`, spreads `IdentitySummary`+`LabelFields`) | done (Root/StatusIcon/TitleArea/Meta/CommentCount) | done | n/a (display only) | done | Uses `withApollo` + `withRouter` |
+| label-badge | done (`LabelFields`) | n/a | done | n/a | done | Uses `withApollo` decorator |
+| pagination | n/a | done (Root/Info/Previous/Next) | done | n/a (links only) | done | |
+| query-input | n/a | done (Root/Icon/Input/Completions) | done | partial (1 play function) | done | Complex autocomplete |
+| section-heading | n/a | n/a | done | n/a | done | |
+| status-badge | n/a | n/a | done | n/a | done | |
+| status-tabs | n/a | done (Root/Tab/OpenIndicator/ClosedIndicator/Count) | done | n/a (links only) | done | |
+| write-preview | n/a | done (Root/Tabs/WriteSlot/PreviewSlot) | done | partial (1 play function) | done | |
+
+## Bug Components (`src/components/bugs/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| timeline | done (5 sub-fragments + connection) | done (internal sub-components use `useSuspenseFragment`) | done | todo (comment editing) | done | 4 stories: FullTimeline, CreateOnly, EmptyMessage, StatusReopen |
+| comment-box | n/a (mutations only) | n/a | todo | todo (textarea, submit, status toggle) | todo | Uses `useAuth`, needs Apollo mock |
+| title-editor | n/a (mutation only) | n/a | done | todo (inline edit, save/cancel, keyboard) | done | Uses `useAuth` |
+| label-editor | partial (uses `LabelFields` via LabelBadge) | n/a | done | todo (dropdown, checkbox toggling) | todo | Demo story with local state |
+
+## Code Components (`src/components/code/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| ref-selector | done (`RefSelectorRefs` on connection) | n/a | done | todo (dropdown, search, keyboard nav) | done | Uses `withApollo` decorator |
+| file-viewer | done (`FileViewerBlob`) | n/a | done | todo (copy, line select, shift-click range) | done | Shiki WASM excluded from browser tests |
+| file-tree | n/a (data from 2 queries, local interface) | n/a | done | n/a (links only) | done | |
+| file-diff-view | todo (owns `DIFF_QUERY`, no fragment) | todo (Hunk is internal) | todo | todo (collapsible sections) | todo | |
+| commit-list | todo (owns `COMMITS_QUERY`, no fragment) | todo (CommitRow is internal) | todo | todo (load more button) | todo | |
+| code-breadcrumb | n/a | n/a | done | n/a (links only) | done | |
+
+## Content Components (`src/components/content/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| markdown | n/a | n/a | done | n/a | done | |
+
+## Layout Components (`src/components/layout/`)
+
+| Component | Fragments | Split/Compound | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|-----------|----------------|---------|-------------------|---------------|-------|
+| header | n/a | todo (RepoNav is internal) | todo | todo (theme toggle) | todo | Uses `useAuth` + router |
+| shell | n/a | n/a | n/a (layout wrapper) | n/a | n/a | |
+
+## UI Primitives (`src/components/ui/`)
+
+All built on @base-ui/react. Fragment/split columns are n/a for these.
+
+| Component | Stories | Interaction Tests | Snapshot Tests | Notes |
+|-----------|---------|-------------------|---------------|-------|
+| avatar | done | n/a | done | Compound (Avatar/Image/Fallback/Badge/Group) |
+| back-link | todo | n/a | todo | |
+| badge | done | n/a | done | |
+| button | done | n/a | done | |
+| button-link | todo | n/a | todo | TanStack Router wrapper |
+| input | done | n/a | done | |
+| listbox | done | n/a | todo | Compound (Content/ScrollArea/Search/Group/Item/Empty) |
+| popover | todo | n/a | todo | |
+| separator | done | n/a | done | |
+| skeleton | done | n/a | done | |
+| textarea | done | n/a | done | |
+
+## Route Pages (no stories expected, but may need component extraction)
+
+| Route | Fragments | Extractable UI | Notes |
+|-------|-----------|----------------|-------|
+| `/$repo/_issues/issues/$id` | done (spreads BugSummary, IdentitySummary, TimelineItems) | todo: participants list | Accesses participant fields directly |
+| `/$repo/_issues/issues/index` | done (spreads BugSummary) | todo: completion providers | Large file, complex query parsing |
+| `/$repo/_issues/user/$id` | partial (spreads BugSummary, IdentitySummary) | todo: profile header | Accesses identity fields directly |
+| `/$repo/_code/tree/$ref/$` | todo | n/a | Accesses tree/commit fields directly, data from 2 queries |
+| `/$repo/commit/$hash` | todo | todo: commit header | Accesses all commit fields directly |
+| `/$repo/_code` | done (uses RefSelectorRefs via preload) | n/a | Layout route |
+| `/$repo/_issues` | done (preloads labels with LabelFields) | n/a | Layout route |
+| `/$repo` | done (REFS_QUERY with RefSelectorRefs) | n/a | Layout route |
+
+## Infrastructure
+
+- **Apollo mock decorator** (`withApollo`): Provides mock `ApolloClient` with `dataMasking: false`, `keyFields` for Label/GitBlob/connections, and mock UserIdentity data for `useAuth()`
+- **Router decorator** (`withRouter`): Provides catch-all TanStack Router for `<Link>` components
+- **Snapshot setup** (`vitest.setup.ts`): Mocks `useSuspenseFragment` as passthrough for happy-dom
@@ -20,6 +20,11 @@ const config: CodegenConfig = {
defaultScalarType: "unknown",
nonOptionalTypename: true,
skipTypeNameForRoot: true,
+ // Generate masked inline fragment types for Apollo's data masking
+ inlineFragmentTypes: "mask",
+ customDirectives: {
+ apolloUnmask: true,
+ },
scalars: {
Time: "string",
Hash: "string",
@@ -20,8 +20,8 @@ type Documents = {
"\n mutation BugStatusOpen($input: BugStatusOpenInput!) {\n bugStatusOpen(input: $input) {\n bug {\n id\n status\n }\n }\n }\n": typeof types.BugStatusOpenDocument,
"\n mutation BugStatusClose($input: BugStatusCloseInput!) {\n bugStatusClose(input: $input) {\n bug {\n id\n status\n }\n }\n }\n": typeof types.BugStatusCloseDocument,
"\n mutation BugChangeLabels($input: BugChangeLabelInput) {\n bugChangeLabels(input: $input) {\n bug {\n id\n labels {\n name\n ...LabelFields\n }\n }\n }\n }\n": typeof types.BugChangeLabelsDocument,
- "\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": typeof types.BugCreateCommentFieldsFragmentDoc,
- "\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": typeof types.BugAddCommentFieldsFragmentDoc,
+ "\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": typeof types.BugCreateCommentFieldsFragmentDoc,
+ "\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": typeof types.BugAddCommentFieldsFragmentDoc,
"\n fragment LabelChangeFields on BugLabelChangeTimelineItem {\n author {\n humanId\n displayName\n }\n date\n added {\n ...LabelFields\n }\n removed {\n ...LabelFields\n }\n }\n": typeof types.LabelChangeFieldsFragmentDoc,
"\n fragment StatusChangeFields on BugSetStatusTimelineItem {\n author {\n humanId\n displayName\n }\n date\n status\n }\n": typeof types.StatusChangeFieldsFragmentDoc,
"\n fragment TitleChangeFields on BugSetTitleTimelineItem {\n author {\n humanId\n displayName\n }\n date\n title\n was\n }\n": typeof types.TitleChangeFieldsFragmentDoc,
@@ -35,7 +35,7 @@ type Documents = {
"\n fragment IdentitySummary on Identity {\n id\n humanId\n displayName\n avatarUrl\n }\n": typeof types.IdentitySummaryFragmentDoc,
"\n fragment BugSummary on Bug {\n id\n humanId\n status\n title\n labels {\n name\n ...LabelFields\n }\n author {\n ...IdentitySummary\n }\n createdAt\n comments {\n totalCount\n }\n }\n": typeof types.BugSummaryFragmentDoc,
"\n fragment LabelFields on Label {\n name\n color {\n R\n G\n B\n }\n }\n": typeof types.LabelFieldsFragmentDoc,
- "\n query UserIdentity {\n repository {\n userIdentity {\n id\n humanId\n name\n displayName\n avatarUrl\n email\n login\n }\n }\n }\n": typeof types.UserIdentityDocument,
+ "\n query UserIdentity {\n repository {\n userIdentity {\n ...IdentitySummary\n id\n humanId\n displayName\n avatarUrl\n name\n email\n login\n }\n }\n }\n": typeof types.UserIdentityDocument,
"\n query CodePageRefs($repo: String) {\n repository(ref: $repo) {\n name\n head {\n shortName\n }\n refs {\n ...RefSelectorRefs\n }\n }\n }\n": typeof types.CodePageRefsDocument,
"\n query CodePageBlob($repo: String, $ref: String!, $path: String!) {\n repository(ref: $repo) {\n blob(ref: $ref, path: $path) {\n ...FileViewerBlob\n }\n }\n }\n": typeof types.CodePageBlobDocument,
"\n query CodePageTree($repo: String, $ref: String!, $path: String) {\n repository(ref: $repo) {\n tree(ref: $ref, path: $path) {\n name\n type\n hash\n }\n }\n }\n": typeof types.CodePageTreeDocument,
@@ -57,8 +57,8 @@ const documents: Documents = {
"\n mutation BugStatusOpen($input: BugStatusOpenInput!) {\n bugStatusOpen(input: $input) {\n bug {\n id\n status\n }\n }\n }\n": types.BugStatusOpenDocument,
"\n mutation BugStatusClose($input: BugStatusCloseInput!) {\n bugStatusClose(input: $input) {\n bug {\n id\n status\n }\n }\n }\n": types.BugStatusCloseDocument,
"\n mutation BugChangeLabels($input: BugChangeLabelInput) {\n bugChangeLabels(input: $input) {\n bug {\n id\n labels {\n name\n ...LabelFields\n }\n }\n }\n }\n": types.BugChangeLabelsDocument,
- "\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": types.BugCreateCommentFieldsFragmentDoc,
- "\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": types.BugAddCommentFieldsFragmentDoc,
+ "\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": types.BugCreateCommentFieldsFragmentDoc,
+ "\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n": types.BugAddCommentFieldsFragmentDoc,
"\n fragment LabelChangeFields on BugLabelChangeTimelineItem {\n author {\n humanId\n displayName\n }\n date\n added {\n ...LabelFields\n }\n removed {\n ...LabelFields\n }\n }\n": types.LabelChangeFieldsFragmentDoc,
"\n fragment StatusChangeFields on BugSetStatusTimelineItem {\n author {\n humanId\n displayName\n }\n date\n status\n }\n": types.StatusChangeFieldsFragmentDoc,
"\n fragment TitleChangeFields on BugSetTitleTimelineItem {\n author {\n humanId\n displayName\n }\n date\n title\n was\n }\n": types.TitleChangeFieldsFragmentDoc,
@@ -72,7 +72,7 @@ const documents: Documents = {
"\n fragment IdentitySummary on Identity {\n id\n humanId\n displayName\n avatarUrl\n }\n": types.IdentitySummaryFragmentDoc,
"\n fragment BugSummary on Bug {\n id\n humanId\n status\n title\n labels {\n name\n ...LabelFields\n }\n author {\n ...IdentitySummary\n }\n createdAt\n comments {\n totalCount\n }\n }\n": types.BugSummaryFragmentDoc,
"\n fragment LabelFields on Label {\n name\n color {\n R\n G\n B\n }\n }\n": types.LabelFieldsFragmentDoc,
- "\n query UserIdentity {\n repository {\n userIdentity {\n id\n humanId\n name\n displayName\n avatarUrl\n email\n login\n }\n }\n }\n": types.UserIdentityDocument,
+ "\n query UserIdentity {\n repository {\n userIdentity {\n ...IdentitySummary\n id\n humanId\n displayName\n avatarUrl\n name\n email\n login\n }\n }\n }\n": types.UserIdentityDocument,
"\n query CodePageRefs($repo: String) {\n repository(ref: $repo) {\n name\n head {\n shortName\n }\n refs {\n ...RefSelectorRefs\n }\n }\n }\n": types.CodePageRefsDocument,
"\n query CodePageBlob($repo: String, $ref: String!, $path: String!) {\n repository(ref: $repo) {\n blob(ref: $ref, path: $path) {\n ...FileViewerBlob\n }\n }\n }\n": types.CodePageBlobDocument,
"\n query CodePageTree($repo: String, $ref: String!, $path: String) {\n repository(ref: $repo) {\n tree(ref: $ref, path: $path) {\n name\n type\n hash\n }\n }\n }\n": types.CodePageTreeDocument,
@@ -129,11 +129,11 @@ export function graphql(source: "\n mutation BugChangeLabels($input: BugChangeL
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"): (typeof documents)["\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"];
+export function graphql(source: "\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"): (typeof documents)["\n fragment BugCreateCommentFields on BugCreateTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"): (typeof documents)["\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n author {\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"];
+export function graphql(source: "\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"): (typeof documents)["\n fragment BugAddCommentFields on BugAddCommentTimelineItem {\n id\n author {\n id\n humanId\n displayName\n ...IdentitySummary\n }\n message\n createdAt\n lastEdit\n edited\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -189,7 +189,7 @@ export function graphql(source: "\n fragment LabelFields on Label {\n name\n
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n query UserIdentity {\n repository {\n userIdentity {\n id\n humanId\n name\n displayName\n avatarUrl\n email\n login\n }\n }\n }\n"): (typeof documents)["\n query UserIdentity {\n repository {\n userIdentity {\n id\n humanId\n name\n displayName\n avatarUrl\n email\n login\n }\n }\n }\n"];
+export function graphql(source: "\n query UserIdentity {\n repository {\n userIdentity {\n ...IdentitySummary\n id\n humanId\n displayName\n avatarUrl\n name\n email\n login\n }\n }\n }\n"): (typeof documents)["\n query UserIdentity {\n repository {\n userIdentity {\n ...IdentitySummary\n id\n humanId\n displayName\n avatarUrl\n name\n email\n login\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1199,25 +1199,55 @@ export type BugChangeLabelsMutationVariables = Exact<{
}>;
-export type BugChangeLabelsMutation = { bugChangeLabels: { __typename: 'BugChangeLabelPayload', bug: { __typename: 'Bug', id: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> } } };
+export type BugChangeLabelsMutation = { bugChangeLabels: { __typename: 'BugChangeLabelPayload', bug: { __typename: 'Bug', id: string, labels: Array<(
+ { __typename: 'Label', name: string }
+ & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+ )> } } };
-export type BugCreateCommentFieldsFragment = { __typename: 'BugCreateTimelineItem', message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } };
+export type BugCreateCommentFieldsFragment = { __typename: 'BugCreateTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: (
+ { __typename: 'Identity', id: string, humanId: string, displayName: string }
+ & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+ ) } & { ' $fragmentName'?: 'BugCreateCommentFieldsFragment' };
-export type BugAddCommentFieldsFragment = { __typename: 'BugAddCommentTimelineItem', message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } };
+export type BugAddCommentFieldsFragment = { __typename: 'BugAddCommentTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: (
+ { __typename: 'Identity', id: string, humanId: string, displayName: string }
+ & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+ ) } & { ' $fragmentName'?: 'BugAddCommentFieldsFragment' };
-export type LabelChangeFieldsFragment = { __typename: 'BugLabelChangeTimelineItem', date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, removed: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> };
+export type LabelChangeFieldsFragment = { __typename: 'BugLabelChangeTimelineItem', date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<(
+ { __typename: 'Label' }
+ & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+ )>, removed: Array<(
+ { __typename: 'Label' }
+ & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+ )> } & { ' $fragmentName'?: 'LabelChangeFieldsFragment' };
-export type StatusChangeFieldsFragment = { __typename: 'BugSetStatusTimelineItem', date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } };
+export type StatusChangeFieldsFragment = { __typename: 'BugSetStatusTimelineItem', date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } } & { ' $fragmentName'?: 'StatusChangeFieldsFragment' };
-export type TitleChangeFieldsFragment = { __typename: 'BugSetTitleTimelineItem', date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } };
+export type TitleChangeFieldsFragment = { __typename: 'BugSetTitleTimelineItem', date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } } & { ' $fragmentName'?: 'TitleChangeFieldsFragment' };
export type TimelineItemsFragment = { __typename: 'BugTimelineItemConnection', nodes: Array<
- | { __typename: 'BugAddCommentTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
- | { __typename: 'BugCreateTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
- | { __typename: 'BugLabelChangeTimelineItem', id: string, date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, removed: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> }
- | { __typename: 'BugSetStatusTimelineItem', id: string, date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } }
- | { __typename: 'BugSetTitleTimelineItem', id: string, date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } }
- > };
+ | (
+ { __typename: 'BugAddCommentTimelineItem', id: string }
+ & { ' $fragmentRefs'?: { 'BugAddCommentFieldsFragment': BugAddCommentFieldsFragment } }
+ )
+ | (
+ { __typename: 'BugCreateTimelineItem', id: string }
+ & { ' $fragmentRefs'?: { 'BugCreateCommentFieldsFragment': BugCreateCommentFieldsFragment } }
+ )
+ | (
+ { __typename: 'BugLabelChangeTimelineItem', id: string }
+ & { ' $fragmentRefs'?: { 'LabelChangeFieldsFragment': LabelChangeFieldsFragment } }
+ )
+ | (
+ { __typename: 'BugSetStatusTimelineItem', id: string }
+ & { ' $fragmentRefs'?: { 'StatusChangeFieldsFragment': StatusChangeFieldsFragment } }
+ )
+ | (
+ { __typename: 'BugSetTitleTimelineItem', id: string }
+ & { ' $fragmentRefs'?: { 'TitleChangeFieldsFragment': TitleChangeFieldsFragment } }
+ )
+ > } & { ' $fragmentName'?: 'TimelineItemsFragment' };
export type BugEditCommentMutationVariables = Exact<{
input: BugEditCommentInput;
@@ -1253,27 +1283,39 @@ export type FileDiffQueryVariables = Exact<{
export type FileDiffQuery = { repository: { __typename: 'Repository', commit: { __typename: 'GitCommit', diff: { __typename: 'GitFileDiff', path: string, oldPath: string | null, isBinary: boolean, isNew: boolean, isDelete: boolean, hunks: Array<{ __typename: 'GitDiffHunk', oldStart: number, oldLines: number, newStart: number, newLines: number, lines: Array<{ __typename: 'GitDiffLine', type: GitDiffLineType, content: string, oldLine: number, newLine: number }> }> } | null } | null } | null };
-export type FileViewerBlobFragment = { __typename: 'GitBlob', path: string, hash: string, text: string | null, size: number, isBinary: boolean, isTruncated: boolean };
+export type FileViewerBlobFragment = { __typename: 'GitBlob', path: string, hash: string, text: string | null, size: number, isBinary: boolean, isTruncated: boolean } & { ' $fragmentName'?: 'FileViewerBlobFragment' };
-export type RefSelectorRefsFragment = { __typename: 'GitRefConnection', nodes: Array<{ __typename: 'GitRef', name: string, shortName: string, type: GitRefType }> };
+export type RefSelectorRefsFragment = { __typename: 'GitRefConnection', nodes: Array<{ __typename: 'GitRef', name: string, shortName: string, type: GitRefType }> } & { ' $fragmentName'?: 'RefSelectorRefsFragment' };
-export type IdentitySummaryFragment = { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null };
+export type IdentitySummaryFragment = { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } & { ' $fragmentName'?: 'IdentitySummaryFragment' };
-export type BugSummaryFragment = { __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } };
+export type BugSummaryFragment = { __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<(
+ { __typename: 'Label', name: string }
+ & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+ )>, author: (
+ { __typename: 'Identity' }
+ & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+ ), comments: { __typename: 'BugCommentConnection', totalCount: number } } & { ' $fragmentName'?: 'BugSummaryFragment' };
-export type LabelFieldsFragment = { __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } };
+export type LabelFieldsFragment = { __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } } & { ' $fragmentName'?: 'LabelFieldsFragment' };
export type UserIdentityQueryVariables = Exact<{ [key: string]: never; }>;
-export type UserIdentityQuery = { repository: { __typename: 'Repository', userIdentity: { __typename: 'Identity', id: string, humanId: string, name: string | null, displayName: string, avatarUrl: string | null, email: string | null, login: string | null } | null } | null };
+export type UserIdentityQuery = { repository: { __typename: 'Repository', userIdentity: (
+ { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null, name: string | null, email: string | null, login: string | null }
+ & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+ ) | null } | null };
export type CodePageRefsQueryVariables = Exact<{
repo?: InputMaybe<Scalars['String']['input']>;
}>;
-export type CodePageRefsQuery = { repository: { __typename: 'Repository', name: string | null, head: { __typename: 'GitRef', shortName: string } | null, refs: { __typename: 'GitRefConnection', nodes: Array<{ __typename: 'GitRef', name: string, shortName: string, type: GitRefType }> } } | null };
+export type CodePageRefsQuery = { repository: { __typename: 'Repository', name: string | null, head: { __typename: 'GitRef', shortName: string } | null, refs: (
+ { __typename: 'GitRefConnection' }
+ & { ' $fragmentRefs'?: { 'RefSelectorRefsFragment': RefSelectorRefsFragment } }
+ ) } | null };
export type CodePageBlobQueryVariables = Exact<{
repo?: InputMaybe<Scalars['String']['input']>;
@@ -1282,7 +1324,10 @@ export type CodePageBlobQueryVariables = Exact<{
}>;
-export type CodePageBlobQuery = { repository: { __typename: 'Repository', blob: { __typename: 'GitBlob', path: string, hash: string, text: string | null, size: number, isBinary: boolean, isTruncated: boolean } | null } | null };
+export type CodePageBlobQuery = { repository: { __typename: 'Repository', blob: (
+ { __typename: 'GitBlob' }
+ & { ' $fragmentRefs'?: { 'FileViewerBlobFragment': FileViewerBlobFragment } }
+ ) | null } | null };
export type CodePageTreeQueryVariables = Exact<{
repo?: InputMaybe<Scalars['String']['input']>;
@@ -1324,7 +1369,10 @@ export type ValidLabelsQueryVariables = Exact<{
}>;
-export type ValidLabelsQuery = { repository: { __typename: 'Repository', validLabels: { __typename: 'LabelConnection', nodes: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> } } | null };
+export type ValidLabelsQuery = { repository: { __typename: 'Repository', validLabels: { __typename: 'LabelConnection', nodes: Array<(
+ { __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }
+ & { ' $fragmentRefs'?: { 'LabelFieldsFragment': LabelFieldsFragment } }
+ )> } } | null };
export type BugDetailQueryVariables = Exact<{
ref?: InputMaybe<Scalars['String']['input']>;
@@ -1332,13 +1380,16 @@ export type BugDetailQueryVariables = Exact<{
}>;
-export type BugDetailQuery = { repository: { __typename: 'Repository', bug: { __typename: 'Bug', lastEdit: string, id: string, humanId: string, status: Status, title: string, createdAt: string, participants: { __typename: 'IdentityConnection', nodes: Array<{ __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }> }, timeline: { __typename: 'BugTimelineItemConnection', nodes: Array<
- | { __typename: 'BugAddCommentTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
- | { __typename: 'BugCreateTimelineItem', id: string, message: string, createdAt: string, lastEdit: string, edited: boolean, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null } }
- | { __typename: 'BugLabelChangeTimelineItem', id: string, date: string, author: { __typename: 'Identity', humanId: string, displayName: string }, added: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, removed: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }> }
- | { __typename: 'BugSetStatusTimelineItem', id: string, date: string, status: Status, author: { __typename: 'Identity', humanId: string, displayName: string } }
- | { __typename: 'BugSetTitleTimelineItem', id: string, date: string, title: string, was: string, author: { __typename: 'Identity', humanId: string, displayName: string } }
- > }, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } } | null } | null };
+export type BugDetailQuery = { repository: { __typename: 'Repository', bug: (
+ { __typename: 'Bug', lastEdit: string, participants: { __typename: 'IdentityConnection', nodes: Array<(
+ { __typename: 'Identity' }
+ & { ' $fragmentRefs'?: { 'IdentitySummaryFragment': IdentitySummaryFragment } }
+ )> }, timeline: (
+ { __typename: 'BugTimelineItemConnection' }
+ & { ' $fragmentRefs'?: { 'TimelineItemsFragment': TimelineItemsFragment } }
+ ) }
+ & { ' $fragmentRefs'?: { 'BugSummaryFragment': BugSummaryFragment } }
+ ) | null } | null };
export type BugListQueryVariables = Exact<{
ref?: InputMaybe<Scalars['String']['input']>;
@@ -1350,7 +1401,10 @@ export type BugListQueryVariables = Exact<{
}>;
-export type BugListQuery = { repository: { __typename: 'Repository', openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<{ __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } }> } } | null };
+export type BugListQuery = { repository: { __typename: 'Repository', openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<(
+ { __typename: 'Bug' }
+ & { ' $fragmentRefs'?: { 'BugSummaryFragment': BugSummaryFragment } }
+ )> } } | null };
export type BugCreateMutationVariables = Exact<{
input: BugCreateInput;
@@ -1369,7 +1423,10 @@ export type UserProfileQueryVariables = Exact<{
}>;
-export type UserProfileQuery = { repository: { __typename: 'Repository', identity: { __typename: 'Identity', id: string, humanId: string, name: string | null, email: string | null, login: string | null, displayName: string, avatarUrl: string | null, isProtected: boolean } | null, openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<{ __typename: 'Bug', id: string, humanId: string, status: Status, title: string, createdAt: string, labels: Array<{ __typename: 'Label', name: string, color: { __typename: 'Color', R: number, G: number, B: number } }>, author: { __typename: 'Identity', id: string, humanId: string, displayName: string, avatarUrl: string | null }, comments: { __typename: 'BugCommentConnection', totalCount: number } }> } } | null };
+export type UserProfileQuery = { repository: { __typename: 'Repository', identity: { __typename: 'Identity', id: string, humanId: string, name: string | null, email: string | null, login: string | null, displayName: string, avatarUrl: string | null, isProtected: boolean } | null, openCount: { __typename: 'BugConnection', totalCount: number }, closedCount: { __typename: 'BugConnection', totalCount: number }, bugs: { __typename: 'BugConnection', totalCount: number, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, endCursor: string }, nodes: Array<(
+ { __typename: 'Bug' }
+ & { ' $fragmentRefs'?: { 'BugSummaryFragment': BugSummaryFragment } }
+ )> } } | null };
export type CommitPageDetailQueryVariables = Exact<{
repo?: InputMaybe<Scalars['String']['input']>;
@@ -1385,13 +1442,13 @@ export type RepositoriesQueryVariables = Exact<{ [key: string]: never; }>;
export type RepositoriesQuery = { repositories: { __typename: 'RepositoryConnection', totalCount: number, nodes: Array<{ __typename: 'Repository', name: string | null }> } };
export const IdentitySummaryFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"IdentitySummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Identity"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"humanId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]} as unknown as DocumentNode<IdentitySummaryFragment, unknown>;
@@ -0,0 +1,6 @@
+import "@apollo/client";
+import type { GraphQLCodegenDataMasking } from "@apollo/client/masking";
+
+declare module "@apollo/client" {
+ interface TypeOverrides extends GraphQLCodegenDataMasking.TypeOverrides {}
+}
@@ -0,0 +1,637 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Timeline/CreateOnly matches snapshot 1`] = `
+<div>
+ <div
+ class="space-y-4"
+ >
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+ data-size="default"
+ data-slot="avatar"
+ >
+ <span
+ class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+ data-slot="avatar-fallback"
+ >
+ JA
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+ <span
+ class="text-muted-foreground"
+ >
+ 1 day ago
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <div
+ class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+ >
+ <p>
+ This is the
+ <strong>
+ initial bug report
+ </strong>
+ with some markdown.
+ </p>
+
+
+ <ul>
+
+
+ <li>
+ Item 1
+ </li>
+
+
+ <li>
+ Item 2
+ </li>
+
+
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Timeline/EmptyMessage matches snapshot 1`] = `
+<div>
+ <div
+ class="space-y-4"
+ >
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+ data-size="default"
+ data-slot="avatar"
+ >
+ <span
+ class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+ data-slot="avatar-fallback"
+ >
+ JA
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+ <span
+ class="text-muted-foreground"
+ >
+ less than a minute ago
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <p
+ class="text-muted-foreground text-sm italic"
+ >
+ No description provided.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Timeline/FullTimeline matches snapshot 1`] = `
+<div>
+ <div
+ class="space-y-4"
+ >
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+ data-size="default"
+ data-slot="avatar"
+ >
+ <span
+ class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+ data-slot="avatar-fallback"
+ >
+ JA
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+ <span
+ class="text-muted-foreground"
+ >
+ 1 day ago
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <div
+ class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+ >
+ <p>
+ This is the
+ <strong>
+ initial bug report
+ </strong>
+ with some markdown.
+ </p>
+
+
+ <ul>
+
+
+ <li>
+ Item 1
+ </li>
+
+
+ <li>
+ Item 2
+ </li>
+
+
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+ >
+ <span
+ class="flex size-8 shrink-0 items-center justify-center"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-tag size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"
+ />
+ <circle
+ cx="7.5"
+ cy="7.5"
+ fill="currentColor"
+ r=".5"
+ />
+ </svg>
+ </span>
+ <span>
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/bob1?status=open&after="
+ >
+ Bob Smith
+ </a>
+
+ added
+
+ <span
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium "
+ style="background-color: rgb(252, 41, 41); color: rgba(255, 255, 255, 0.9);"
+ >
+ bug
+ </span>
+
+ about 12 hours ago
+ </span>
+ </div>
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+ data-size="default"
+ data-slot="avatar"
+ >
+ <span
+ class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+ data-slot="avatar-fallback"
+ >
+ BO
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/bob1?status=open&after="
+ >
+ Bob Smith
+ </a>
+ <span
+ class="text-muted-foreground"
+ >
+ about 6 hours ago
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <div
+ class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+ >
+ <p>
+ I can reproduce this. The issue is in the login handler.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+ >
+ <span
+ class="flex size-8 shrink-0 items-center justify-center"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-pencil size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
+ />
+ <path
+ d="m15 5 4 4"
+ />
+ </svg>
+ </span>
+ <span>
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+
+ changed the title from
+ <span
+ class="line-through"
+ >
+ Login page crash
+ </span>
+ to
+
+ <span
+ class="text-foreground font-medium"
+ >
+ Login page crash on empty email input
+ </span>
+
+ about 2 hours ago
+ </span>
+ </div>
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+ data-size="default"
+ data-slot="avatar"
+ >
+ <span
+ class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+ data-slot="avatar-fallback"
+ >
+ JA
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+ <span
+ class="text-muted-foreground"
+ >
+ about 1 hour ago
+ </span>
+ <span
+ class="text-muted-foreground text-xs"
+ >
+ edited
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <div
+ class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+ >
+ <p>
+ Fixed in commit abc123. The email validator was not handling empty strings.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+ >
+ <span
+ class="flex size-8 shrink-0 items-center justify-center"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-git-pull-request-closed size-4 text-purple-600 dark:text-purple-400"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <circle
+ cx="6"
+ cy="6"
+ r="3"
+ />
+ <path
+ d="M6 9v12"
+ />
+ <path
+ d="m21 3-6 6"
+ />
+ <path
+ d="m21 9-6-6"
+ />
+ <path
+ d="M18 11.5V15"
+ />
+ <circle
+ cx="18"
+ cy="18"
+ r="3"
+ />
+ </svg>
+ </span>
+ <span>
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+
+ closed
+ this
+
+ about 1 hour ago
+ </span>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Timeline/StatusReopen matches snapshot 1`] = `
+<div>
+ <div
+ class="space-y-4"
+ >
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="group/avatar relative flex rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten mt-1 size-8 shrink-0"
+ data-size="default"
+ data-slot="avatar"
+ >
+ <span
+ class="flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground group-data-[size=sm]/avatar:text-xs text-xs"
+ data-slot="avatar-fallback"
+ >
+ JA
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+ <span
+ class="text-muted-foreground"
+ >
+ 1 day ago
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <div
+ class="prose prose-sm dark:prose-invert max-w-none prose-pre:rounded-md prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-sm prose-pre:overflow-x-auto prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-img:inline prose-img:my-0"
+ >
+ <p>
+ This is the
+ <strong>
+ initial bug report
+ </strong>
+ with some markdown.
+ </p>
+
+
+ <ul>
+
+
+ <li>
+ Item 1
+ </li>
+
+
+ <li>
+ Item 2
+ </li>
+
+
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+ >
+ <span
+ class="flex size-8 shrink-0 items-center justify-center"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-git-pull-request-closed size-4 text-purple-600 dark:text-purple-400"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <circle
+ cx="6"
+ cy="6"
+ r="3"
+ />
+ <path
+ d="M6 9v12"
+ />
+ <path
+ d="m21 3-6 6"
+ />
+ <path
+ d="m21 9-6-6"
+ />
+ <path
+ d="M18 11.5V15"
+ />
+ <circle
+ cx="18"
+ cy="18"
+ r="3"
+ />
+ </svg>
+ </span>
+ <span>
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/bob1?status=open&after="
+ >
+ Bob Smith
+ </a>
+
+ closed
+ this
+
+ about 2 hours ago
+ </span>
+ </div>
+ <div
+ class="text-muted-foreground flex items-center gap-3 pl-2 text-sm"
+ >
+ <span
+ class="flex size-8 shrink-0 items-center justify-center"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-circle-dot size-4 text-green-600 dark:text-green-400"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <circle
+ cx="12"
+ cy="12"
+ r="10"
+ />
+ <circle
+ cx="12"
+ cy="12"
+ r="1"
+ />
+ </svg>
+ </span>
+ <span>
+ <a
+ class="text-foreground font-medium hover:underline"
+ href="/_/user/jane1?status=open&after="
+ >
+ Jane Doe
+ </a>
+
+ reopened
+ this
+
+ about 1 hour ago
+ </span>
+ </div>
+ </div>
+</div>
+`;
@@ -0,0 +1,91 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`TitleEditor/Default matches snapshot 1`] = `
+<div>
+ <div
+ class="group flex items-start gap-2"
+ >
+ <h1
+ class="text-foreground flex-1 text-2xl leading-tight font-semibold"
+ >
+ Fix login page crash on empty email
+ <span
+ class="text-muted-foreground ml-2 text-xl font-normal"
+ >
+ #
+ a1b2c3
+ </span>
+ </h1>
+ <button
+ class="text-muted-foreground hover:text-foreground mt-1 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
+ title="Edit title"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-pencil size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
+ />
+ <path
+ d="m15 5 4 4"
+ />
+ </svg>
+ </button>
+ </div>
+</div>
+`;
+
+exports[`TitleEditor/LongTitle matches snapshot 1`] = `
+<div>
+ <div
+ class="group flex items-start gap-2"
+ >
+ <h1
+ class="text-foreground flex-1 text-2xl leading-tight font-semibold"
+ >
+ Very long issue title that spans multiple lines and tests how the component handles overflow in the layout
+ <span
+ class="text-muted-foreground ml-2 text-xl font-normal"
+ >
+ #
+ d4e5f6
+ </span>
+ </h1>
+ <button
+ class="text-muted-foreground hover:text-foreground mt-1 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
+ title="Edit title"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-pencil size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
+ />
+ <path
+ d="m15 5 4 4"
+ />
+ </svg>
+ </button>
+ </div>
+</div>
+`;
@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
-import { LabelBadge } from "@/components/shared/label-badge";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
+import { LabelBadge, LABEL_FIELDS_FRAGMENT } from "@/components/shared/label-badge";
import { SectionHeading } from "@/components/shared/section-heading";
import * as Listbox from "@/components/ui/listbox";
import {
@@ -22,30 +24,38 @@ import { useRef } from "react";
// The real LabelEditor depends on GraphQL mutations. For stories, we build a
// self-contained version with the same UI but local state instead of mutations.
-const allLabels = [
- { name: "bug", color: { R: 252, G: 41, B: 41 } },
- { name: "enhancement", color: { R: 0, G: 150, B: 255 } },
- { name: "documentation", color: { R: 0, G: 180, B: 80 } },
- { name: "help wanted", color: { R: 255, G: 152, B: 0 } },
- { name: "good first issue", color: { R: 124, G: 58, B: 237 } },
+const allLabelsData = [
+ { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } },
+ { __typename: "Label" as const, name: "enhancement", color: { R: 0, G: 150, B: 255 } },
+ { __typename: "Label" as const, name: "documentation", color: { R: 0, G: 180, B: 80 } },
+ { __typename: "Label" as const, name: "help wanted", color: { R: 255, G: 152, B: 0 } },
+ { __typename: "Label" as const, name: "good first issue", color: { R: 124, G: 58, B: 237 } },
];
+const allLabels = allLabelsData.map(
+ (l) => ({ ...l, ...makeFragmentData(l, LABEL_FIELDS_FRAGMENT) }),
+);
+
type LabelColor = { R: number; G: number; B: number };
+type BrandedLabel = (typeof allLabels)[number];
function LabelEditorDemo() {
- const [current, setCurrent] = useState<Array<{ name: string; color: LabelColor }>>([
- allLabels[0]!,
- allLabels[2]!,
- ]);
-
- const currentNames = new Set(current.map((l) => l.name));
+ const [currentNames, setCurrentNames] = useState<Set<string>>(
+ new Set([allLabelsData[0]!.name, allLabelsData[2]!.name]),
+ );
- function toggleLabel(label: { name: string; color: LabelColor }) {
- if (currentNames.has(label.name)) {
- setCurrent((prev) => prev.filter((l) => l.name !== label.name));
- } else {
- setCurrent((prev) => [...prev, label]);
- }
+ const currentLabels = allLabels.filter((l) => currentNames.has(l.name));
+
+ function toggleLabel(label: { name: string }) {
+ setCurrentNames((prev) => {
+ const next = new Set(prev);
+ if (next.has(label.name)) {
+ next.delete(label.name);
+ } else {
+ next.add(label.name);
+ }
+ return next;
+ });
}
const [open, setOpen] = useState(false);
@@ -120,7 +130,7 @@ function LabelEditorDemo() {
: {}
}
/>
- <LabelBadge name={label.name} color={label.color} />
+ <LabelBadge label={label} />
</Listbox.Item>
);
})}
@@ -130,12 +140,12 @@ function LabelEditorDemo() {
</FloatingPortal>
)}
- {current.length === 0 ? (
+ {currentLabels.length === 0 ? (
<p className="text-muted-foreground text-sm">None yet</p>
) : (
<div className="flex flex-wrap gap-1">
- {current.map((label) => (
- <LabelBadge key={label.name} name={label.name} color={label.color} />
+ {currentLabels.map((label) => (
+ <LabelBadge key={label.name} label={label} />
))}
</div>
)}
@@ -144,13 +154,17 @@ function LabelEditorDemo() {
}
const meta = {
- title: "bugs/LabelEditor",
+ component: LabelEditorDemo,
+ decorators: [
+ withApollo,
+ withCachedFragments(
+ ...allLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+ ),
+ ],
parameters: { layout: "centered", a11y: { disable: true } },
-} satisfies Meta;
+} satisfies Meta<typeof LabelEditorDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
-export const Default: Story = {
- render: () => <LabelEditorDemo />,
-};
+export const Default: Story = {};
@@ -14,7 +14,7 @@ import {
import { Settings2 } from "lucide-react";
import { useRef, useState } from "react";
-import type { FragmentType } from "@apollo/client/masking";
+import type { FragmentType } from "@/__generated__/fragment-masking";
import { BugDetailDocument } from "@/__generated__/graphql";
import { graphql } from "@/__generated__/gql";
import * as Listbox from "@/components/ui/listbox";
@@ -0,0 +1,173 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { Status } from "@/__generated__/graphql";
+import { withApollo, withCachedFragments, withRouter } from "@/../.storybook/decorators";
+
+import { Timeline, TIMELINE_ITEMS_FRAGMENT } from "./timeline";
+
+const meta = {
+ component: Timeline,
+ decorators: [withRouter, withApollo],
+ parameters: { a11y: { disable: true } },
+} satisfies Meta<typeof Timeline>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const jane = {
+ __typename: "Identity" as const,
+ id: "u1",
+ humanId: "jane1",
+ displayName: "Jane Doe",
+ avatarUrl: null,
+};
+
+const bob = {
+ __typename: "Identity" as const,
+ id: "u2",
+ humanId: "bob1",
+ displayName: "Bob Smith",
+ avatarUrl: "https://github.com/shadcn.png",
+};
+
+const baseTimelineData = {
+ __typename: "BugTimelineItemConnection" as const,
+ nodes: [
+ {
+ __typename: "BugCreateTimelineItem" as const,
+ id: "create-1",
+ author: jane,
+ message: "This is the **initial bug report** with some markdown.\n\n- Item 1\n- Item 2",
+ createdAt: new Date(Date.now() - 86400 * 1000).toISOString(),
+ lastEdit: new Date(Date.now() - 86400 * 1000).toISOString(),
+ edited: false,
+ },
+ {
+ __typename: "BugLabelChangeTimelineItem" as const,
+ id: "label-1",
+ author: { humanId: "bob1", displayName: "Bob Smith" },
+ date: new Date(Date.now() - 43200 * 1000).toISOString(),
+ added: [{ __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } }],
+ removed: [],
+ },
+ {
+ __typename: "BugAddCommentTimelineItem" as const,
+ id: "comment-1",
+ author: bob,
+ message: "I can reproduce this. The issue is in the login handler.",
+ createdAt: new Date(Date.now() - 21600 * 1000).toISOString(),
+ lastEdit: new Date(Date.now() - 21600 * 1000).toISOString(),
+ edited: false,
+ },
+ {
+ __typename: "BugSetTitleTimelineItem" as const,
+ id: "title-1",
+ author: { humanId: "jane1", displayName: "Jane Doe" },
+ date: new Date(Date.now() - 7200 * 1000).toISOString(),
+ title: "Login page crash on empty email input",
+ was: "Login page crash",
+ },
+ {
+ __typename: "BugAddCommentTimelineItem" as const,
+ id: "comment-2",
+ author: jane,
+ message: "Fixed in commit abc123. The email validator was not handling empty strings.",
+ createdAt: new Date(Date.now() - 3600 * 1000).toISOString(),
+ lastEdit: new Date(Date.now() - 1800 * 1000).toISOString(),
+ edited: true,
+ },
+ {
+ __typename: "BugSetStatusTimelineItem" as const,
+ id: "status-1",
+ author: { humanId: "jane1", displayName: "Jane Doe" },
+ date: new Date(Date.now() - 3600 * 1000).toISOString(),
+ status: Status.Closed,
+ },
+ ],
+};
+
+const baseTimeline = makeFragmentData(baseTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const FullTimeline: Story = {
+ decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", baseTimelineData])],
+ args: {
+ repo: "_",
+ bugPrefix: "abc123",
+ timeline: baseTimeline,
+ },
+};
+
+const createOnlyTimelineData = {
+ __typename: "BugTimelineItemConnection" as const,
+ nodes: [baseTimelineData.nodes[0]],
+};
+
+const createOnlyTimeline = makeFragmentData(createOnlyTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const CreateOnly: Story = {
+ decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", createOnlyTimelineData])],
+ args: {
+ repo: "_",
+ bugPrefix: "abc123",
+ timeline: createOnlyTimeline,
+ },
+};
+
+const emptyMessageTimelineData = {
+ __typename: "BugTimelineItemConnection" as const,
+ nodes: [
+ {
+ __typename: "BugCreateTimelineItem" as const,
+ id: "create-empty",
+ author: jane,
+ message: "",
+ createdAt: new Date().toISOString(),
+ lastEdit: new Date().toISOString(),
+ edited: false,
+ },
+ ],
+};
+
+const emptyMessageTimeline = makeFragmentData(emptyMessageTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const EmptyMessage: Story = {
+ decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", emptyMessageTimelineData])],
+ args: {
+ repo: "_",
+ bugPrefix: "abc123",
+ timeline: emptyMessageTimeline,
+ },
+};
+
+const statusReopenTimelineData = {
+ __typename: "BugTimelineItemConnection" as const,
+ nodes: [
+ baseTimelineData.nodes[0],
+ {
+ __typename: "BugSetStatusTimelineItem" as const,
+ id: "status-close",
+ author: { humanId: "bob1", displayName: "Bob Smith" },
+ date: new Date(Date.now() - 7200 * 1000).toISOString(),
+ status: Status.Closed,
+ },
+ {
+ __typename: "BugSetStatusTimelineItem" as const,
+ id: "status-reopen",
+ author: { humanId: "jane1", displayName: "Jane Doe" },
+ date: new Date(Date.now() - 3600 * 1000).toISOString(),
+ status: Status.Open,
+ },
+ ],
+};
+
+const statusReopenTimeline = makeFragmentData(statusReopenTimelineData, TIMELINE_ITEMS_FRAGMENT);
+
+export const StatusReopen: Story = {
+ decorators: [withCachedFragments([TIMELINE_ITEMS_FRAGMENT, "TimelineItems", statusReopenTimelineData])],
+ args: {
+ repo: "_",
+ bugPrefix: "abc123",
+ timeline: statusReopenTimeline,
+ },
+};
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./timeline.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`Timeline/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
@@ -1,5 +1,6 @@
-import { useMutation, useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useMutation } from "@apollo/client/react";
+import type { ResultOf } from "@graphql-typed-document-node/core";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
import { Link } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { Tag, GitPullRequestClosed, Pencil, CircleDot } from "lucide-react";
@@ -14,9 +15,15 @@ import { LabelBadge } from "@/components/shared/label-badge";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
-graphql(`
+// ββ Sub-fragments ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const BUG_CREATE_COMMENT_FRAGMENT = graphql(`
fragment BugCreateCommentFields on BugCreateTimelineItem {
+ id
author {
+ id
+ humanId
+ displayName
...IdentitySummary
}
message
@@ -26,9 +33,13 @@ graphql(`
}
`);
-graphql(`
+const BUG_ADD_COMMENT_FRAGMENT = graphql(`
fragment BugAddCommentFields on BugAddCommentTimelineItem {
+ id
author {
+ id
+ humanId
+ displayName
...IdentitySummary
}
message
@@ -38,7 +49,7 @@ graphql(`
}
`);
-graphql(`
+const LABEL_CHANGE_FRAGMENT = graphql(`
fragment LabelChangeFields on BugLabelChangeTimelineItem {
author {
humanId
@@ -54,7 +65,7 @@ graphql(`
}
`);
-graphql(`
+const STATUS_CHANGE_FRAGMENT = graphql(`
fragment StatusChangeFields on BugSetStatusTimelineItem {
author {
humanId
@@ -65,7 +76,7 @@ graphql(`
}
`);
-graphql(`
+const TITLE_CHANGE_FRAGMENT = graphql(`
fragment TitleChangeFields on BugSetTitleTimelineItem {
author {
humanId
@@ -77,6 +88,8 @@ graphql(`
}
`);
+// ββ Connection-level fragment ββββββββββββββββββββββββββββββββββββββββββββββββ
+
export const TIMELINE_ITEMS_FRAGMENT = graphql(`
fragment TimelineItems on BugTimelineItemConnection {
nodes {
@@ -101,6 +114,8 @@ export const TIMELINE_ITEMS_FRAGMENT = graphql(`
}
`);
+// ββ Mutation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
const BUG_EDIT_COMMENT_MUTATION = graphql(`
mutation BugEditComment($input: BugEditCommentInput!) {
bugEditComment(input: $input) {
@@ -111,11 +126,13 @@ const BUG_EDIT_COMMENT_MUTATION = graphql(`
}
`);
-type TimelineData = ReturnType<
- typeof useSuspenseFragment<typeof TIMELINE_ITEMS_FRAGMENT>
->["data"];
+// ββ Type helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+type TimelineData = ResultOf<typeof TIMELINE_ITEMS_FRAGMENT>;
type TimelineNode = TimelineData["nodes"][number];
+// ββ Timeline βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
interface TimelineProps {
repo: string | null;
bugPrefix: string;
@@ -126,18 +143,16 @@ interface TimelineProps {
// inline events (label changes, status changes, title edits). Comment items
// support inline editing for the logged-in user.
export function Timeline({ repo, bugPrefix, timeline }: TimelineProps) {
- const { data } = useSuspenseFragment({
- fragment: TIMELINE_ITEMS_FRAGMENT,
- from: timeline,
- });
+ const data = useFragment(TIMELINE_ITEMS_FRAGMENT, timeline);
return (
<div className="space-y-4">
{data.nodes.map((item) => {
switch (item.__typename) {
case "BugCreateTimelineItem":
+ return <CreateCommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
case "BugAddCommentTimelineItem":
- return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
+ return <AddCommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
case "BugLabelChangeTimelineItem":
return <LabelChangeItem key={item.id} item={item} repo={repo} />;
case "BugSetStatusTimelineItem":
@@ -152,19 +167,28 @@ export function Timeline({ repo, bugPrefix, timeline }: TimelineProps) {
);
}
-// ββ Comment (create or add-comment) ββββββββββββββββββββββββββββββββββββββββββ
+// ββ Comment items ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-type CommentItem = Extract<
- TimelineNode,
- { __typename: "BugCreateTimelineItem" | "BugAddCommentTimelineItem" }
->;
+type CreateNode = Extract<TimelineNode, { __typename: "BugCreateTimelineItem" }>;
+type AddCommentNode = Extract<TimelineNode, { __typename: "BugAddCommentTimelineItem" }>;
+type CommentData = ResultOf<typeof BUG_CREATE_COMMENT_FRAGMENT>;
+
+function CreateCommentItem({ item, bugPrefix, repo }: { item: CreateNode; bugPrefix: string; repo: string | null }) {
+ const data = useFragment(BUG_CREATE_COMMENT_FRAGMENT, item);
+ return <CommentBody data={data} bugPrefix={bugPrefix} repo={repo} />;
+}
+
+function AddCommentItem({ item, bugPrefix, repo }: { item: AddCommentNode; bugPrefix: string; repo: string | null }) {
+ const data = useFragment(BUG_ADD_COMMENT_FRAGMENT, item);
+ return <CommentBody data={data} bugPrefix={bugPrefix} repo={repo} />;
+}
-function CommentItem({
- item,
+function CommentBody({
+ data: item,
bugPrefix,
repo,
}: {
- item: CommentItem;
+ data: CommentData;
bugPrefix: string;
repo: string | null;
}) {
@@ -258,11 +282,11 @@ function CommentItem({
);
}
-// ββ Inline events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// ββ Inline events ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-type LabelChangeItem = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
-type StatusChangeItem = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
-type TitleChangeItem = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
+type LabelChangeNode = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
+type StatusChangeNode = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
+type TitleChangeNode = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
function EventRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
@@ -273,42 +297,44 @@ function EventRow({ icon, children }: { icon: React.ReactNode; children: React.R
);
}
-function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string | null }) {
+function LabelChangeItem({ item, repo }: { item: LabelChangeNode; repo: string | null }) {
+ const data = useFragment(LABEL_CHANGE_FRAGMENT, item);
return (
<EventRow icon={<Tag className="size-4" />}>
<span>
<Link
to="/$repo/user/$id"
- params={{ repo: repo!, id: item.author.humanId }}
+ params={{ repo: repo!, id: data.author.humanId }}
search={{ status: "open" as const, after: "" }}
className="text-foreground font-medium hover:underline"
>
- {item.author.displayName}
+ {data.author.displayName}
</Link>{" "}
- {item.added.length > 0 && (
+ {data.added.length > 0 && (
<>
added{" "}
- {item.added.map((l, i) => (
+ {data.added.map((l, i) => (
<LabelBadge key={i} label={l} />
))}{" "}
</>
)}
- {item.removed.length > 0 && (
+ {data.removed.length > 0 && (
<>
removed{" "}
- {item.removed.map((l, i) => (
+ {data.removed.map((l, i) => (
<LabelBadge key={i} label={l} />
))}{" "}
</>
)}
- {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
+ {formatDistanceToNow(new Date(data.date), { addSuffix: true })}
</span>
</EventRow>
);
}
-function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string | null }) {
- const isOpen = item.status === Status.Open;
+function StatusChangeItem({ item, repo }: { item: StatusChangeNode; repo: string | null }) {
+ const data = useFragment(STATUS_CHANGE_FRAGMENT, item);
+ const isOpen = data.status === Status.Open;
return (
<EventRow
icon={
@@ -322,34 +348,35 @@ function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string
<span>
<Link
to="/$repo/user/$id"
- params={{ repo: repo!, id: item.author.humanId }}
+ params={{ repo: repo!, id: data.author.humanId }}
search={{ status: "open" as const, after: "" }}
className="text-foreground font-medium hover:underline"
>
- {item.author.displayName}
+ {data.author.displayName}
</Link>{" "}
{isOpen ? "reopened" : "closed"} this{" "}
- {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
+ {formatDistanceToNow(new Date(data.date), { addSuffix: true })}
</span>
</EventRow>
);
}
-function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string | null }) {
+function TitleChangeItem({ item, repo }: { item: TitleChangeNode; repo: string | null }) {
+ const data = useFragment(TITLE_CHANGE_FRAGMENT, item);
return (
<EventRow icon={<Pencil className="size-4" />}>
<span>
<Link
to="/$repo/user/$id"
- params={{ repo: repo!, id: item.author.humanId }}
+ params={{ repo: repo!, id: data.author.humanId }}
search={{ status: "open" as const, after: "" }}
className="text-foreground font-medium hover:underline"
>
- {item.author.displayName}
+ {data.author.displayName}
</Link>{" "}
- changed the title from <span className="line-through">{item.was}</span> to{" "}
- <span className="text-foreground font-medium">{item.title}</span>{" "}
- {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
+ changed the title from <span className="line-through">{data.was}</span> to{" "}
+ <span className="text-foreground font-medium">{data.title}</span>{" "}
+ {formatDistanceToNow(new Date(data.date), { addSuffix: true })}
</span>
</EventRow>
);
@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { withApollo } from "@/../.storybook/decorators";
+
+import { TitleEditor } from "./title-editor";
+
+const meta = {
+ component: TitleEditor,
+ decorators: [withApollo],
+} satisfies Meta<typeof TitleEditor>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+ args: {
+ bugPrefix: "abc123",
+ title: "Fix login page crash on empty email",
+ humanId: "a1b2c3",
+ },
+};
+
+export const LongTitle: Story = {
+ args: {
+ bugPrefix: "def456",
+ title: "Very long issue title that spans multiple lines and tests how the component handles overflow in the layout",
+ humanId: "d4e5f6",
+ },
+};
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./title-editor.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`TitleEditor/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
@@ -60,23 +60,117 @@ exports[`FileViewer/BinaryFile matches snapshot 1`] = `
exports[`FileViewer/Loading matches snapshot 1`] = `
<div>
<div
- class="divide-border border-border divide-y rounded-md border"
+ class="border-border overflow-hidden rounded-md border"
>
<div
- class="flex items-center gap-2 px-4 py-2"
+ class="bg-muted/40 border-border flex items-center justify-between border-b px-4 py-2"
>
<div
- class="animate-pulse rounded-md bg-muted h-4 w-48"
+ class="animate-pulse rounded-md bg-muted h-4 w-32"
data-slot="skeleton"
/>
</div>
<div
- class="p-4"
+ class="flex"
>
<div
- class="animate-pulse rounded-md bg-muted h-64 w-full"
- data-slot="skeleton"
- />
+ class="flex flex-col items-end gap-1 px-3 py-3"
+ >
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5 w-6"
+ data-slot="skeleton"
+ />
+ </div>
+ <div
+ class="flex flex-1 flex-col gap-1 py-3 pr-4"
+ >
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 30%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 77%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 64%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 51%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 38%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 85%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 72%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 59%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 46%;"
+ />
+ <div
+ class="animate-pulse rounded-md bg-muted h-3.5"
+ data-slot="skeleton"
+ style="width: 33%;"
+ />
+ </div>
</div>
</div>
</div>
@@ -1,21 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { FileViewer } from "./file-viewer";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
+import { Skeleton } from "@/components/ui/skeleton";
-const meta = {
- component: FileViewer,
- // Skip browser tests β Shiki's WASM engine doesn't load in Vitest browser mode.
- // Snapshot tests (happy-dom) still cover this component.
- tags: ["!test"],
-} satisfies Meta<typeof FileViewer>;
+import { FileViewer, FILE_VIEWER_BLOB_FRAGMENT } from "./file-viewer";
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-export const TypeScriptFile: Story = {
- args: {
- blob: {
- text: `import { useState } from "react";
+const typescriptBlobData = {
+ __typename: "GitBlob" as const,
+ text: `import { useState } from "react";
interface CounterProps {
initial?: number;
@@ -33,43 +26,91 @@ export function Counter({ initial = 0 }: CounterProps) {
</div>
);
}`,
- hash: "abc123",
- path: "src/Counter.tsx",
- size: 312,
- isBinary: false,
- isTruncated: false,
- },
+ hash: "abc123",
+ path: "src/Counter.tsx",
+ size: 312,
+ isBinary: false,
+ isTruncated: false,
+};
+
+const binaryBlobData = {
+ __typename: "GitBlob" as const,
+ text: null,
+ hash: "def456",
+ path: "logo.png",
+ size: 24576,
+ isBinary: true,
+ isTruncated: false,
+};
+
+const truncatedBlobData = {
+ __typename: "GitBlob" as const,
+ text: "line 1\nline 2\nline 3\n... (truncated)",
+ hash: "ghi789",
+ path: "large-file.log",
+ size: 1048576,
+ isBinary: false,
+ isTruncated: true,
+};
+
+const meta = {
+ component: FileViewer,
+ decorators: [
+ withApollo,
+ withCachedFragments(
+ [FILE_VIEWER_BLOB_FRAGMENT, "FileViewerBlob", typescriptBlobData],
+ [FILE_VIEWER_BLOB_FRAGMENT, "FileViewerBlob", binaryBlobData],
+ [FILE_VIEWER_BLOB_FRAGMENT, "FileViewerBlob", truncatedBlobData],
+ ),
+ ],
+ // Skip browser tests β Shiki's WASM engine doesn't load in Vitest browser mode.
+ // Snapshot tests (happy-dom) still cover this component.
+ tags: ["!test"],
+} satisfies Meta<typeof FileViewer>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const typescriptBlob = makeFragmentData(typescriptBlobData, FILE_VIEWER_BLOB_FRAGMENT);
+const binaryBlob = makeFragmentData(binaryBlobData, FILE_VIEWER_BLOB_FRAGMENT);
+const truncatedBlob = makeFragmentData(truncatedBlobData, FILE_VIEWER_BLOB_FRAGMENT);
+
+export const TypeScriptFile: Story = {
+ args: {
+ blob: typescriptBlob,
},
};
export const BinaryFile: Story = {
args: {
- blob: {
- text: null,
- hash: "def456",
- path: "logo.png",
- size: 24576,
- isBinary: true,
- isTruncated: false,
- },
+ blob: binaryBlob,
},
};
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,
- },
+ blob: truncatedBlob,
},
};
export const Loading: Story = {
- args: {
- blob: null,
- },
+ render: () => (
+ <div className="border-border overflow-hidden rounded-md border">
+ <div className="bg-muted/40 border-border flex items-center justify-between border-b px-4 py-2">
+ <Skeleton className="h-4 w-32" />
+ </div>
+ <div className="flex">
+ <div className="flex flex-col items-end gap-1 px-3 py-3">
+ {Array.from({ length: 10 }).map((_, i) => (
+ <Skeleton key={i} className="h-3.5 w-6" />
+ ))}
+ </div>
+ <div className="flex flex-1 flex-col gap-1 py-3 pr-4">
+ {Array.from({ length: 10 }).map((_, i) => (
+ <Skeleton key={i} className="h-3.5" style={{ width: `${30 + ((i * 47) % 60)}%` }} />
+ ))}
+ </div>
+ </div>
+ </div>
+ ),
};
@@ -8,8 +8,7 @@ import { useState, useEffect, useCallback, Fragment, type ReactNode } from "reac
import { jsx, jsxs } from "react/jsx-runtime";
import type { ShikiTransformer } from "shiki/core";
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
import { graphql } from "@/__generated__/gql";
import { Button } from "@/components/ui/button";
@@ -142,10 +141,7 @@ interface FileViewerProps {
}
export function FileViewer({ blob: blobProp }: FileViewerProps) {
- const { data: blob } = useSuspenseFragment({
- fragment: FILE_VIEWER_BLOB_FRAGMENT,
- from: blobProp,
- });
+ const blob = useFragment(FILE_VIEWER_BLOB_FRAGMENT, blobProp);
const [highlighted, setHighlighted] = useState<{ node: ReactNode; lineCount: number } | null>(
null,
@@ -1,31 +1,51 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
import { GitRefType } from "@/__generated__/graphql";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
-import { RefSelector } from "./ref-selector";
+import { RefSelector, REF_SELECTOR_REFS_FRAGMENT } from "./ref-selector";
+
+const sampleRefsData = {
+ __typename: "GitRefConnection" as const,
+ nodes: [
+ { name: "refs/heads/main", shortName: "main", type: GitRefType.Branch },
+ { name: "refs/heads/develop", shortName: "develop", type: GitRefType.Branch },
+ { name: "refs/heads/feature/auth", shortName: "feature/auth", type: GitRefType.Branch },
+ { name: "refs/heads/fix/login", shortName: "fix/login", type: GitRefType.Branch },
+ { name: "refs/tags/v1.0.0", shortName: "v1.0.0", type: GitRefType.Tag },
+ { name: "refs/tags/v1.1.0", shortName: "v1.1.0", type: GitRefType.Tag },
+ { name: "refs/tags/v2.0.0-rc1", shortName: "v2.0.0-rc1", type: GitRefType.Tag },
+ ],
+};
+
+const branchesOnlyData = {
+ __typename: "GitRefConnection" as const,
+ nodes: sampleRefsData.nodes.filter((r) => r.type === GitRefType.Branch),
+};
+
+const sampleRefs = makeFragmentData(sampleRefsData, REF_SELECTOR_REFS_FRAGMENT);
+const branchesOnly = makeFragmentData(branchesOnlyData, REF_SELECTOR_REFS_FRAGMENT);
const meta = {
component: RefSelector,
+ decorators: [
+ withApollo,
+ withCachedFragments(
+ [REF_SELECTOR_REFS_FRAGMENT, "RefSelectorRefs", sampleRefsData],
+ [REF_SELECTOR_REFS_FRAGMENT, "RefSelectorRefs", branchesOnlyData],
+ ),
+ ],
parameters: { a11y: { disable: true } },
} satisfies Meta<typeof RefSelector>;
export default meta;
type Story = StoryObj<typeof meta>;
-const sampleRefs = [
- { name: "refs/heads/main", shortName: "main", type: GitRefType.Branch, hash: "abc1" },
- { name: "refs/heads/develop", shortName: "develop", type: GitRefType.Branch, hash: "abc2" },
- { name: "refs/heads/feature/auth", shortName: "feature/auth", type: GitRefType.Branch, hash: "abc3" },
- { name: "refs/heads/fix/login", shortName: "fix/login", type: GitRefType.Branch, hash: "abc4" },
- { name: "refs/tags/v1.0.0", shortName: "v1.0.0", type: GitRefType.Tag, hash: "abc5" },
- { name: "refs/tags/v1.1.0", shortName: "v1.1.0", type: GitRefType.Tag, hash: "abc6" },
- { name: "refs/tags/v2.0.0-rc1", shortName: "v2.0.0-rc1", type: GitRefType.Tag, hash: "abc7" },
-];
-
export const Default: Story = {
args: {
- gitRefs: sampleRefs,
+ refs: sampleRefs,
currentRef: "main",
onSelect: fn(),
},
@@ -33,7 +53,7 @@ export const Default: Story = {
export const OnTag: Story = {
args: {
- gitRefs: sampleRefs,
+ refs: sampleRefs,
currentRef: "v1.1.0",
onSelect: fn(),
},
@@ -41,7 +61,7 @@ export const OnTag: Story = {
export const BranchesOnly: Story = {
args: {
- gitRefs: sampleRefs.filter((r) => r.type === GitRefType.Branch),
+ refs: branchesOnly,
currentRef: "develop",
onSelect: fn(),
},
@@ -1,5 +1,4 @@
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
import {
useFloating,
useClick,
@@ -40,7 +39,7 @@ interface RefSelectorProps {
// Branch / tag selector dropdown for the code browser. Shown in two groups
// (branches, tags) with an inline search filter.
export function RefSelector({ refs: refsProp, currentRef, onSelect }: RefSelectorProps) {
- const { data } = useSuspenseFragment({ fragment: REF_SELECTOR_REFS_FRAGMENT, from: refsProp });
+ const data = useFragment(REF_SELECTOR_REFS_FRAGMENT, refsProp);
const gitRefs = data.nodes;
const [open, setOpen] = useState(false);
@@ -1,47 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import type { IdentitySummaryFragment } from "@/__generated__/graphql";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
import * as CommentCard from "./comment-card";
+import { IDENTITY_SUMMARY_FRAGMENT } from "./comment-card";
-const meta = {
- component: CommentCard.Root,
- parameters: { a11y: { disable: true } },
-} satisfies Meta<typeof CommentCard.Root>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-// Mock data shaped like IdentitySummaryFragment
-const jane: IdentitySummaryFragment = {
+const janeData = {
+ __typename: "Identity" as const,
id: "1",
humanId: "jane1",
displayName: "Jane Doe",
avatarUrl: null,
};
-const bob: IdentitySummaryFragment = {
+const bobData = {
+ __typename: "Identity" as const,
id: "2",
humanId: "bob1",
displayName: "Bob Smith",
avatarUrl: "https://github.com/shadcn.png",
};
-const alice: IdentitySummaryFragment = {
+const aliceData = {
+ __typename: "Identity" as const,
id: "3",
humanId: "alice1",
displayName: "Alice Wu",
avatarUrl: null,
};
+const jane = makeFragmentData(janeData, IDENTITY_SUMMARY_FRAGMENT);
+const bob = makeFragmentData(bobData, IDENTITY_SUMMARY_FRAGMENT);
+const alice = makeFragmentData(aliceData, IDENTITY_SUMMARY_FRAGMENT);
+
+const meta = {
+ component: CommentCard.Root,
+ decorators: [
+ withApollo,
+ withCachedFragments(
+ [IDENTITY_SUMMARY_FRAGMENT, "IdentitySummary", janeData],
+ [IDENTITY_SUMMARY_FRAGMENT, "IdentitySummary", bobData],
+ [IDENTITY_SUMMARY_FRAGMENT, "IdentitySummary", aliceData],
+ ),
+ ],
+ parameters: { a11y: { disable: true } },
+} satisfies Meta<typeof CommentCard.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
export const Default: Story = {
args: { children: null },
render: () => (
<CommentCard.Root>
- <CommentCard.AuthorAvatar displayName={jane.displayName} avatarUrl={jane.avatarUrl} />
+ <CommentCard.AuthorAvatar author={jane} />
<CommentCard.Card>
<CommentCard.CardHeader>
- <span className="text-foreground font-medium">{jane.displayName}</span>
+ <span className="text-foreground font-medium">{janeData.displayName}</span>
<span className="text-muted-foreground">2 hours ago</span>
</CommentCard.CardHeader>
<CommentCard.CardBody>
@@ -56,10 +72,10 @@ export const WithEditButton: Story = {
args: { children: null },
render: () => (
<CommentCard.Root>
- <CommentCard.AuthorAvatar displayName={bob.displayName} avatarUrl={bob.avatarUrl} />
+ <CommentCard.AuthorAvatar author={bob} />
<CommentCard.Card>
<CommentCard.CardHeader>
- <span className="text-foreground font-medium">{bob.displayName}</span>
+ <span className="text-foreground font-medium">{bobData.displayName}</span>
<span className="text-muted-foreground">1 day ago</span>
<span className="text-muted-foreground text-xs">edited</span>
<button className="text-muted-foreground hover:bg-muted ml-auto rounded-sm px-1.5 py-0.5 text-xs">
@@ -78,10 +94,10 @@ export const EmptyBody: Story = {
args: { children: null },
render: () => (
<CommentCard.Root>
- <CommentCard.AuthorAvatar displayName={alice.displayName} avatarUrl={alice.avatarUrl} />
+ <CommentCard.AuthorAvatar author={alice} />
<CommentCard.Card>
<CommentCard.CardHeader>
- <span className="text-foreground font-medium">{alice.displayName}</span>
+ <span className="text-foreground font-medium">{aliceData.displayName}</span>
<span className="text-muted-foreground">just now</span>
</CommentCard.CardHeader>
<CommentCard.CardBody>
@@ -1,5 +1,4 @@
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
import { graphql } from "@/__generated__/gql";
import { cn } from "@/lib/utils";
@@ -30,7 +29,7 @@ interface AuthorAvatarProps {
}
export function AuthorAvatar({ author, className }: AuthorAvatarProps) {
- const { data } = useSuspenseFragment({ fragment: IDENTITY_SUMMARY_FRAGMENT, from: author });
+ const data = useFragment(IDENTITY_SUMMARY_FRAGMENT, author);
return (
<Avatar className={cn("mt-1 size-8 shrink-0", className)}>
@@ -2,28 +2,27 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { useState } from "react";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
import type { SortValue } from "@/lib/query-utils";
import { IssueFilters, type LabelItem, type IdentityItem } from "./issue-filters";
+import { LABEL_FIELDS_FRAGMENT } from "./label-badge";
-const meta = {
- component: IssueFilters,
- parameters: { layout: "centered", a11y: { disable: true } },
-} satisfies Meta<typeof IssueFilters>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-
-const sampleLabels: LabelItem[] = [
- { name: "bug", color: { R: 252, G: 41, B: 41 } },
- { name: "enhancement", color: { R: 0, G: 150, B: 255 } },
- { name: "documentation", color: { R: 0, G: 180, B: 80 } },
- { name: "help wanted", color: { R: 255, G: 152, B: 0 } },
- { name: "good first issue", color: { R: 124, G: 58, B: 237 } },
- { name: "duplicate", color: { R: 120, G: 120, B: 120 } },
- { name: "wontfix", color: { R: 180, G: 180, B: 180 } },
+const sampleLabelsData = [
+ { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } },
+ { __typename: "Label" as const, name: "enhancement", color: { R: 0, G: 150, B: 255 } },
+ { __typename: "Label" as const, name: "documentation", color: { R: 0, G: 180, B: 80 } },
+ { __typename: "Label" as const, name: "help wanted", color: { R: 255, G: 152, B: 0 } },
+ { __typename: "Label" as const, name: "good first issue", color: { R: 124, G: 58, B: 237 } },
+ { __typename: "Label" as const, name: "duplicate", color: { R: 120, G: 120, B: 120 } },
+ { __typename: "Label" as const, name: "wontfix", color: { R: 180, G: 180, B: 180 } },
];
+const sampleLabels: LabelItem[] = sampleLabelsData.map(
+ (l) => ({ ...l, ...makeFragmentData(l, LABEL_FIELDS_FRAGMENT) }),
+);
+
const sampleIdentities: IdentityItem[] = [
{ id: "u1", humanId: "abc1", displayName: "Jane Doe", login: "janedoe", name: "Jane Doe", email: "jane@example.com", avatarUrl: null },
{ id: "u2", humanId: "abc2", displayName: "John Smith", login: "jsmith", name: "John Smith", email: "john@example.com", avatarUrl: null },
@@ -32,6 +31,20 @@ const sampleIdentities: IdentityItem[] = [
{ id: "u5", humanId: "abc5", displayName: "Carol Tester", login: "carol", name: "Carol Tester", email: "carol@example.com", avatarUrl: null },
];
+const meta = {
+ component: IssueFilters,
+ decorators: [
+ withApollo,
+ withCachedFragments(
+ ...sampleLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+ ),
+ ],
+ parameters: { layout: "centered", a11y: { disable: true } },
+} satisfies Meta<typeof IssueFilters>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
export const Default: Story = {
args: {
labels: sampleLabels,
@@ -14,7 +14,7 @@ import {
import { ArrowUpDown, ChevronDown, Tag, User, X } from "lucide-react";
import { useMemo, useRef, useState, useCallback, useEffect } from "react";
-import type { FragmentType } from "@apollo/client/masking";
+import type { FragmentType } from "@/__generated__/fragment-masking";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import * as Listbox from "@/components/ui/listbox";
import { useAuth } from "@/lib/auth";
@@ -1,20 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { formatDistanceToNow } from "date-fns";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
import type { BugSummaryFragment } from "@/__generated__/graphql";
import { Status } from "@/__generated__/graphql";
-import { withRouter } from "@/../.storybook/decorators";
+import { withApollo, withCachedFragments, withRouter } from "@/../.storybook/decorators";
import * as IssueRow from "./issue-row";
-import { LabelBadge } from "./label-badge";
+import { LabelBadge, LABEL_FIELDS_FRAGMENT } from "./label-badge";
-const meta = {
- component: IssueRow.Root,
- decorators: [withRouter],
-} satisfies Meta<typeof IssueRow.Root>;
+const bugLabel = { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } };
+const priorityLabel = { __typename: "Label" as const, name: "priority", color: { R: 255, G: 152, B: 0 } };
+const enhancementLabel = { __typename: "Label" as const, name: "enhancement", color: { R: 163, G: 230, B: 53 } };
-export default meta;
-type Story = StoryObj<typeof meta>;
+const allLabelsData = [bugLabel, priorityLabel, enhancementLabel];
// Mock data shaped like BugSummaryFragment from GraphQL
const openBug: BugSummaryFragment = {
@@ -23,10 +22,10 @@ const openBug: BugSummaryFragment = {
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 } },
+ makeFragmentData(bugLabel, LABEL_FIELDS_FRAGMENT),
+ makeFragmentData(priorityLabel, LABEL_FIELDS_FRAGMENT),
],
- author: { id: "u1", humanId: "user1", displayName: "Jane Doe", avatarUrl: null },
+ author: { __typename: "Identity", id: "u1", humanId: "user1", displayName: "Jane Doe", avatarUrl: null },
createdAt: new Date(Date.now() - 3600 * 1000).toISOString(),
comments: { totalCount: 3 },
};
@@ -36,8 +35,8 @@ const closedBug: BugSummaryFragment = {
humanId: "d4e5f6",
status: Status.Closed,
title: "Add dark mode support",
- labels: [{ name: "enhancement", color: { R: 163, G: 230, B: 53 } }],
- author: { id: "u2", humanId: "user2", displayName: "Bob Smith", avatarUrl: null },
+ labels: [makeFragmentData(enhancementLabel, LABEL_FIELDS_FRAGMENT)],
+ author: { __typename: "Identity", id: "u2", humanId: "user2", displayName: "Bob Smith", avatarUrl: null },
createdAt: new Date(Date.now() - 86400 * 1000).toISOString(),
comments: { totalCount: 12 },
};
@@ -48,11 +47,25 @@ const noLabelsBug: BugSummaryFragment = {
status: Status.Open,
title: "Simple issue with no labels",
labels: [],
- author: { id: "u3", humanId: "user3", displayName: "Alice Wu", avatarUrl: null },
+ author: { __typename: "Identity", id: "u3", humanId: "user3", displayName: "Alice Wu", avatarUrl: null },
createdAt: new Date(Date.now() - 7200 * 1000).toISOString(),
comments: { totalCount: 0 },
};
+const meta = {
+ component: IssueRow.Root,
+ decorators: [
+ withRouter,
+ withApollo,
+ withCachedFragments(
+ ...allLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+ ),
+ ],
+} satisfies Meta<typeof IssueRow.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
function BugRow({ bug }: { bug: BugSummaryFragment }) {
const ago = formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true });
return (
@@ -64,7 +77,7 @@ function BugRow({ bug }: { bug: BugSummaryFragment }) {
{bug.title}
</a>
{bug.labels.map((l) => (
- <LabelBadge key={l.name} {...l} />
+ <LabelBadge key={l.name} label={l} />
))}
</IssueRow.TitleArea>
<IssueRow.Meta>
@@ -1,52 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
-import type { LabelFieldsFragment } from "@/__generated__/graphql";
+import { makeFragmentData } from "@/__generated__/fragment-masking";
+import { withApollo, withCachedFragments } from "@/../.storybook/decorators";
-import { LabelBadge } from "./label-badge";
+import { LabelBadge, LABEL_FIELDS_FRAGMENT } from "./label-badge";
+
+const bugData = { __typename: "Label" as const, name: "bug", color: { R: 252, G: 41, B: 41 } };
+const enhancementData = { __typename: "Label" as const, name: "enhancement", color: { R: 163, G: 230, B: 53 } };
+const documentationData = { __typename: "Label" as const, name: "documentation", color: { R: 30, G: 80, B: 160 } };
+const helpWantedData = { __typename: "Label" as const, name: "help wanted", color: { R: 0, G: 150, B: 136 } };
+const wontfixData = { __typename: "Label" as const, name: "wontfix", color: { R: 200, G: 200, B: 200 } };
+const priorityData = { __typename: "Label" as const, name: "priority", color: { R: 255, G: 152, B: 0 } };
+
+const allLabelsData = [bugData, enhancementData, documentationData, helpWantedData, wontfixData, priorityData];
+
+const bug = makeFragmentData(bugData, LABEL_FIELDS_FRAGMENT);
+const enhancement = makeFragmentData(enhancementData, LABEL_FIELDS_FRAGMENT);
+const documentation = makeFragmentData(documentationData, LABEL_FIELDS_FRAGMENT);
+const helpWanted = makeFragmentData(helpWantedData, LABEL_FIELDS_FRAGMENT);
+const wontfix = makeFragmentData(wontfixData, LABEL_FIELDS_FRAGMENT);
+const priority = makeFragmentData(priorityData, LABEL_FIELDS_FRAGMENT);
+
+const allLabels = [bug, enhancement, documentation, helpWanted, wontfix, priority];
const meta = {
component: LabelBadge,
+ decorators: [
+ withApollo,
+ withCachedFragments(
+ ...allLabelsData.map((l) => [LABEL_FIELDS_FRAGMENT, "LabelFields", l] as const),
+ ),
+ ],
} satisfies Meta<typeof LabelBadge>;
export default meta;
type Story = StoryObj<typeof meta>;
-// Mock data shaped like LabelFieldsFragment from GraphQL
-const bug: LabelFieldsFragment = { name: "bug", color: { R: 252, G: 41, B: 41 } };
-const enhancement: LabelFieldsFragment = { name: "enhancement", color: { R: 163, G: 230, B: 53 } };
-const documentation: LabelFieldsFragment = { name: "documentation", color: { R: 30, G: 80, B: 160 } };
-const helpWanted: LabelFieldsFragment = { name: "help wanted", color: { R: 0, G: 150, B: 136 } };
-const wontfix: LabelFieldsFragment = { name: "wontfix", color: { R: 200, G: 200, B: 200 } };
-const priority: LabelFieldsFragment = { name: "priority", color: { R: 255, G: 152, B: 0 } };
-
-const allLabels = [bug, enhancement, documentation, helpWanted, wontfix, priority];
-
export const Default: Story = {
parameters: { a11y: { disable: true } },
- args: bug,
+ args: { label: bug },
};
export const LightBackground: Story = {
- args: enhancement,
+ args: { label: enhancement },
};
export const DarkBackground: Story = {
- args: documentation,
+ args: { label: documentation },
};
export const Clickable: Story = {
parameters: { a11y: { disable: true } },
- args: { ...helpWanted, onClick: fn() },
+ args: { label: helpWanted, onClick: fn() },
};
export const AllColors: Story = {
parameters: { a11y: { disable: true } },
- args: bug,
+ args: { label: bug },
render: () => (
<div className="flex flex-wrap gap-2">
- {allLabels.map((label) => (
- <LabelBadge key={label.name} {...label} />
+ {allLabelsData.map((data, i) => (
+ <LabelBadge key={data.name} label={allLabels[i]!} />
))}
</div>
),
@@ -1,5 +1,4 @@
-import { useSuspenseFragment } from "@apollo/client/react";
-import type { FragmentType } from "@apollo/client/masking";
+import { useFragment, type FragmentType } from "@/__generated__/fragment-masking";
import { createLink, type LinkComponent } from "@tanstack/react-router";
import * as React from "react";
@@ -32,7 +31,7 @@ const LabelBadge = React.forwardRef<
HTMLSpanElement,
LabelBadgeProps & Omit<React.HTMLAttributes<HTMLSpanElement>, "color">
>(({ label, className, ...props }, ref) => {
- const { data } = useSuspenseFragment({ fragment: LABEL_FIELDS_FRAGMENT, from: label });
+ const data = useFragment(LABEL_FIELDS_FRAGMENT, label);
const bg = `rgb(${data.color.R},${data.color.G},${data.color.B})`;
const text = contrastColor(data.color.R, data.color.G, data.color.B);
@@ -54,8 +53,8 @@ const CreatedLabelBadgeLink = createLink(
React.forwardRef<
HTMLAnchorElement,
LabelBadgeProps & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "color">
- >(({ from, className, ...props }, ref) => {
- const { data } = useSuspenseFragment({ fragment: LABEL_FIELDS_FRAGMENT, from });
+ >(({ label, className, ...props }, ref) => {
+ const data = useFragment(LABEL_FIELDS_FRAGMENT, label);
const bg = `rgb(${data.color.R},${data.color.G},${data.color.B})`;
const text = contrastColor(data.color.R, data.color.G, data.color.B);
@@ -18,12 +18,10 @@ import { useRef, useState, useEffect } from "react";
import { Button } from "./button";
import * as Listbox from "./listbox";
-// We can't use `component:` for a namespace import, so we target Content as
-// the "primary" component just to give Storybook a title.
const meta = {
- title: "ui/Listbox",
+ component: Listbox.Content,
parameters: { layout: "centered", a11y: { disable: true } },
-} satisfies Meta;
+} satisfies Meta<typeof Listbox.Content>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -9,7 +9,12 @@ const httpLink = new HttpLink({
export const client = new ApolloClient({
link: httpLink,
- dataMasking: true,
+ // Data masking is off: fragment colocation is enforced at the type level
+ // via codegen's $fragmentRefs branding (inlineFragmentTypes: "mask").
+ // Components use codegen's useFragment (a zero-cost cast) to unmask.
+ // When @defer is needed, individual components can switch to Apollo's
+ // useSuspenseFragment β it works without dataMasking.
+ dataMasking: false,
cache: new InMemoryCache({
typePolicies: {
@@ -11,11 +11,12 @@ export const USER_IDENTITY_QUERY = graphql(`
query UserIdentity {
repository {
userIdentity {
+ ...IdentitySummary
id
humanId
- name
displayName
avatarUrl
+ name
email
login
}
@@ -23,17 +24,7 @@ export const USER_IDENTITY_QUERY = graphql(`
}
`);
-export interface AuthUser {
- id: string;
- humanId: string;
- name: string | null;
- displayName: string;
- avatarUrl: string | null;
- email: string | null;
- login: string | null;
-}
-
-export function useAuth(): { user: AuthUser } {
+export function useAuth() {
const { data } = useSuspenseQuery(USER_IDENTITY_QUERY);
- return { user: data.repository!.userIdentity! as AuthUser };
+ return { user: data.repository!.userIdentity! };
}
@@ -10,9 +10,9 @@ const commitsSearchSchema = v.object({
});
export const Route = createFileRoute("/$repo/_code/commits/$ref")({
- component: CommitsView,
- beforeLoad: () => ({ viewMode: "commits" as const }),
validateSearch: (search) => v.parse(commitsSearchSchema, search),
+ beforeLoad: () => ({ viewMode: "commits" as const }),
+ component: CommitsView,
});
function CommitsView() {