authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-14 20:46:28+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 14:46:28-03:00
log47ace94962a714a561d7cfbdbaa4c721ef6b0598
tree20a976f04f86931bf5b462f62a06e6bc4efc8ff4
parent494cfac2494095c3f9b24cc16fb73721f84894a4
signature Signed by PGP key B5690EEEBB952194

fix(web): use consistent PR section toggles (#11763)


2 files changed, 193 insertions(+), 72 deletions(-)

apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx created+141
...@@ -0,0 +1,141 @@
1import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts";
2import { act } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
7vi.mock("~/state/pullRequests", () => ({ pullRequestEnvironment: {} }));
8vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() }));
9vi.mock("./PullRequestMarkdown", () => ({
10 PullRequestMarkdown: ({ text }: { text: string }) => <p>{text}</p>,
11}));
12
13import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
14
15const detail: PullRequestDetailView = {
16 provider: "github",
17 projectId: ProjectId.make("project"),
18 projectTitle: "Project",
19 workspaceRoot: "/workspace",
20 repository: "owner/repo",
21 number: 1,
22 title: "Test pull request",
23 body: "Original description",
24 url: "https://github.com/owner/repo/pull/1",
25 author: { login: "author", name: null, avatarUrl: null },
26 viewer: "author",
27 state: "open",
28 isDraft: false,
29 mergeability: "mergeable",
30 additions: 1,
31 deletions: 0,
32 changedFiles: 1,
33 headBranch: "feature",
34 baseBranch: "main",
35 createdAt: "2026-09-01T00:00:00Z",
36 updatedAt: "2026-09-01T00:00:00Z",
37 mergedAt: null,
38 closedAt: null,
39 reviewers: [],
40 labels: [],
41 checks: [{ name: "Unit tests", status: "success", description: null, url: null }],
42 comments: [],
43 commentCount: 0,
44 commentsTruncated: false,
45 reviewThreads: [],
46 commits: [],
47 mergeCapabilities: { merge: false, squash: false, rebase: false },
48 capabilities: {
49 diff: true,
50 comment: true,
51 search: true,
52 actions: [],
53 mergeMethods: [],
54 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
55 reviewers: { request: false, listCandidates: false },
56 edit: { changeRequest: true, comment: true },
57 },
58 viewerPermissions: {
59 actions: [],
60 comment: true,
61 resolve: false,
62 verdicts: [],
63 requestReviewers: false,
64 },
65};
66
67let renderer: ReactTestRenderer;
68beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));
69afterEach(() => {
70 act(() => renderer?.unmount());
71 vi.unstubAllGlobals();
72});
73
74function render(value = detail) {
75 return (
76 <PullRequestSummaryTab
77 environmentId={EnvironmentId.make("environment")}
78 threadRef={null}
79 reference={value}
80 detail={value}
81 activityPending={false}
82 activityError={null}
83 onRefresh={() => {}}
84 />
85 );
86}
87
88function heading(title: string) {
89 const section = renderer.root
90 .findAllByType("section")
91 .find((section) => section.props["aria-label"] === title)!;
92 return section
93 .findAllByType("button")
94 .find((button) => button.findAllByType("span").some((span) => span.children.includes(title)))!;
95}
96
97function click(title: string) {
98 act(() =>
99 heading(title).props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} }),
100 );
101}
102
103it("toggles checks from their heading and resets sections for another pull request", () => {
104 act(() => {
105 renderer = create(render());
106 });
107 expect(
108 renderer.root.findAllByType("span").some((span) => span.children.includes("Unit tests")),
109 ).toBe(false);
110 click("Checks");
111 expect(
112 renderer.root.findAllByType("span").some((span) => span.children.includes("Unit tests")),
113 ).toBe(true);
114 click("Checks");
115 expect(heading("Checks").props["aria-expanded"]).toBe(false);
116 click("Checks");
117 click("Description");
118 act(() =>
119 renderer.update(render({ ...detail, url: "https://github.com/owner/repo/pull/2", number: 2 })),
120 );
121 expect(heading("Checks").props["aria-expanded"]).toBe(false);
122 expect(heading("Description").props["aria-expanded"]).toBe(true);
123});
124
125it("keeps an unsaved description when collapsed and reopened", () => {
126 act(() => {
127 renderer = create(render());
128 });
129 act(() => renderer.root.findByProps({ "aria-label": "Edit description" }).props.onClick());
130 act(() =>
131 renderer.root.findByType("textarea").props.onChange({
132 target: { value: "Unsaved description" },
133 currentTarget: { value: "Unsaved description" },
134 nativeEvent: {},
135 }),
136 );
137 click("Description");
138 expect(heading("Description").props["aria-expanded"]).toBe(false);
139 click("Description");
140 expect(renderer.root.findByType("textarea").props.value).toBe("Unsaved description");
141});
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+52-72
...@@ -15,7 +15,7 @@ import {...@@ -15,7 +15,7 @@ import {
15 TagIcon,15 TagIcon,
16 UsersIcon,16 UsersIcon,
17} from "lucide-react";17} from "lucide-react";
18import { useId, useRef, useState, type ReactNode } from "react";18import { useRef, useState, type ReactNode } from "react";
1919
20import { useAtomCommand } from "~/state/use-atom-command";20import { useAtomCommand } from "~/state/use-atom-command";
21import { pullRequestEnvironment } from "~/state/pullRequests";21import { pullRequestEnvironment } from "~/state/pullRequests";
...@@ -237,11 +237,13 @@ function MetaRow({...@@ -237,11 +237,13 @@ function MetaRow({
237function Section({237function Section({
238 title,238 title,
239 defaultOpen = true,239 defaultOpen = true,
240 keepMounted = false,
240 actions,241 actions,
241 children,242 children,
242}: {243}: {
243 title: string;244 title: string;
244 defaultOpen?: boolean;245 defaultOpen?: boolean;
246 keepMounted?: boolean;
245 /** Heading controls stay separate from the collapse trigger so they remain independently usable. */247 /** Heading controls stay separate from the collapse trigger so they remain independently usable. */
246 actions?: ReactNode;248 actions?: ReactNode;
247 children: ReactNode;249 children: ReactNode;
...@@ -271,6 +273,7 @@ function Section({...@@ -271,6 +273,7 @@ function Section({
271 <Collapsible273 <Collapsible
272 open={open}274 open={open}
273 onOpenChange={setOpenWithScrollAnchor}275 onOpenChange={setOpenWithScrollAnchor}
276 render={<section aria-label={title} />}
274 data-pull-request-summary-section277 data-pull-request-summary-section
275 >278 >
276 {/* The heading rides the top of the scroll box the way a diff's file header does, so a279 {/* The heading rides the top of the scroll box the way a diff's file header does, so a
...@@ -292,7 +295,7 @@ function Section({...@@ -292,7 +295,7 @@ function Section({
292 </CollapsibleTrigger>295 </CollapsibleTrigger>
293 {actions}296 {actions}
294 </div>297 </div>
295 <CollapsiblePanel>298 <CollapsiblePanel keepMounted={keepMounted}>
296 <div className="px-4 pb-4">{children}</div>299 <div className="px-4 pb-4">{children}</div>
297 </CollapsiblePanel>300 </CollapsiblePanel>
298 </Collapsible>301 </Collapsible>
...@@ -334,9 +337,6 @@ export function PullRequestSummaryTab({...@@ -334,9 +337,6 @@ export function PullRequestSummaryTab({
334 // Keyed by the pull request, so opening another one starts at the end of its conversation337 // Keyed by the pull request, so opening another one starts at the end of its conversation
335 // rather than wherever the last one had been read back to.338 // rather than wherever the last one had been read back to.
336 const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE });339 const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE });
337 const checksId = useId();
338 const [expandedChecksUrl, setExpandedChecksUrl] = useState<string | null>(null);
339 const showChecks = expandedChecksUrl === detail.url;
340 const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE;340 const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE;
341 // Windowed by recency regardless of display order: expanding always reaches further back in341 // Windowed by recency regardless of display order: expanding always reaches further back in
342 // time, whether the newest comment currently reads first or last.342 // time, whether the newest comment currently reads first or last.
...@@ -472,7 +472,7 @@ export function PullRequestSummaryTab({...@@ -472,7 +472,7 @@ export function PullRequestSummaryTab({
472472
473 return (473 return (
474 <div className="h-full overflow-y-auto" data-pull-request-summary-scroll>474 <div className="h-full overflow-y-auto" data-pull-request-summary-scroll>
475 <section className="px-4 py-2.5">475 <section className="px-4 pt-2.5 pb-1">
476 <div className="space-y-2">476 <div className="space-y-2">
477 <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers">477 <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers">
478 <span className="flex min-w-0 flex-wrap items-center gap-1.5">478 <span className="flex min-w-0 flex-wrap items-center gap-1.5">
...@@ -601,7 +601,7 @@ export function PullRequestSummaryTab({...@@ -601,7 +601,7 @@ export function PullRequestSummaryTab({
601 </div>601 </div>
602 </section>602 </section>
603603
604 <section aria-label="Description" className="px-4 pt-2 pb-1">604 <Section key={`description:${detail.url}`} title="Description" keepMounted>
605 <div className="group">605 <div className="group">
606 {bodyScope === detail.url ? (606 {bodyScope === detail.url ? (
607 <PullRequestMarkdownEditor607 <PullRequestMarkdownEditor
...@@ -640,78 +640,58 @@ export function PullRequestSummaryTab({...@@ -640,78 +640,58 @@ export function PullRequestSummaryTab({
640 </div>640 </div>
641 )}641 )}
642 </div>642 </div>
643 </section>643 </Section>
644644
645 <section aria-label="Checks" className="px-4 py-3">645 <Section key={`checks:${detail.url}`} title="Checks" defaultOpen={false}>
646 {detail.checks.length === 0 ? (646 {detail.checks.length === 0 ? (
647 <p className="text-xs text-muted-foreground">No checks reported.</p>647 <p className="text-xs text-muted-foreground">No checks reported.</p>
648 ) : (648 ) : (
649 <div>649 detail.checks.map((check, index) => {
650 <div className="flex items-center gap-1 text-xs">650 const finding = { kind: "check", check } as const;
651 <span className="font-medium text-muted-foreground">Checks</span>651 const failing = check.status === "failure" || check.status === "cancelled";
652 <Button652 return (
653 size="icon-xs"653 <div
654 variant="ghost-muted"654 // Position too: the host decides how many runs share a name, and a repeated
655 aria-label={showChecks ? "Hide checks" : "Show checks"}655 // key would be a rendering fault on top of whatever the list already says.
656 aria-expanded={showChecks}656 key={`${index}:${check.name}:${check.url ?? ""}`}
657 aria-controls={checksId}657 className="group flex items-center gap-2 rounded-md pr-1 hover:bg-accent/60"
658 onClick={() => setExpandedChecksUrl(showChecks ? null : detail.url)}
659 >658 >
660 <ChevronRightIcon659 <button
661 aria-hidden660 type="button"
662 className={cn("size-3.5 text-muted-foreground/60", showChecks && "rotate-90")}661 disabled={!check.url}
663 />662 onClick={() => check.url && openCheck(check.url)}
664 </Button>663 className={cn(
665 </div>664 "flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-2 text-left text-xs leading-5 [&>svg]:mt-0.5",
666 <div id={checksId} className={showChecks ? "mt-2" : "hidden"}>665 check.url ? "cursor-pointer" : "cursor-default",
667 {(showChecks ? detail.checks : []).map((check, index) => {666 )}
668 const finding = { kind: "check", check } as const;667 >
669 const failing = check.status === "failure" || check.status === "cancelled";668 <PullRequestCheckStatusIcon status={check.status} />
670 return (669 <span className="min-w-0 flex-1 wrap-anywhere">{check.name}</span>
671 <div670 <span className="shrink-0 text-muted-foreground">
672 // Position too: the host decides how many runs share a name, and a repeated671 {pullRequestCheckStatusLabel(check)}
673 // key would be a rendering fault on top of whatever the list already says.672 </span>
674 key={`${index}:${check.name}:${check.url ?? ""}`}673 </button>
675 className="group flex items-center gap-2 rounded-md pr-1 hover:bg-accent/60"674 {/* Only where there is something to fix. A passing check has no failure to
676 >
677 <button
678 type="button"
679 disabled={!check.url}
680 onClick={() => check.url && openCheck(check.url)}
681 className={cn(
682 "flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-2 text-left text-xs leading-5 [&>svg]:mt-0.5",
683 check.url ? "cursor-pointer" : "cursor-default",
684 )}
685 >
686 <PullRequestCheckStatusIcon status={check.status} />
687 <span className="min-w-0 flex-1 wrap-anywhere">{check.name}</span>
688 <span className="shrink-0 text-muted-foreground">
689 {pullRequestCheckStatusLabel(check)}
690 </span>
691 </button>
692 {/* Only where there is something to fix. A passing check has no failure to
693 reproduce, and the button would be an invitation to waste a thread. */}675 reproduce, and the button would be an invitation to waste a thread. */}
694 {onFixFinding && failing ? (676 {onFixFinding && failing ? (
695 <Button677 <Button
696 size="xs"678 size="xs"
697 variant="ghost"679 variant="ghost"
698 className="shrink-0"680 className="shrink-0"
699 disabled={pendingFinding !== null && pendingFinding !== undefined}681 disabled={pendingFinding !== null && pendingFinding !== undefined}
700 onClick={() => onFixFinding(finding)}682 onClick={() => onFixFinding(finding)}
701 >683 >
702 <HammerIcon className="size-3" />684 <HammerIcon className="size-3" />
703 {pendingFinding === pullRequestFindingKey(finding)685 {pendingFinding === pullRequestFindingKey(finding)
704 ? "Preparing..."686 ? "Preparing..."
705 : fixCheckLabel}687 : fixCheckLabel}
706 </Button>688 </Button>
707 ) : null}689 ) : null}
708 </div>690 </div>
709 );691 );
710 })}692 })
711 </div>
712 </div>
713 )}693 )}
714 </section>694 </Section>
715695
716 <Section696 <Section
717 title="Comments"697 title="Comments"