Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions apps/dashboard/src/components/pulls/pull-request-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ViewIcon,
} from "@quickhub/icons";
import { Markdown } from "@quickhub/ui/components/markdown";
import { cn } from "@quickhub/ui/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
Expand Down Expand Up @@ -68,9 +69,12 @@ export function PullRequestRow({
<div className="rounded-lg">
<Link
to={href}
className={`group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:[&:not(:has([data-action]:hover))]:bg-surface-1 ${expanded ? "bg-surface-1" : ""}`}
className={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:[&:not(:has([data-action]:hover))]:bg-surface-1",
expanded && "bg-surface-1",
)}
>
<div className={`mt-[3px] shrink-0 ${color}`}>
<div className={cn("mt-[3px] shrink-0", color)}>
<Icon size={16} strokeWidth={2} />
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1">
Expand Down Expand Up @@ -148,7 +152,10 @@ export function PullRequestRow({
{commentsQuery.data.map((comment, i) => (
<div
key={comment.id}
className={`flex flex-col gap-1 ${i === commentsQuery.data!.length - 1 ? "pb-4" : ""}`}
className={cn(
"flex flex-col gap-1",
i === commentsQuery.data.length - 1 && "pb-4",
)}
>
<div className="flex items-center gap-1.5">
{comment.author && (
Expand Down
261 changes: 224 additions & 37 deletions apps/dashboard/src/routes/_protected/pulls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import {
CommentIcon,
GitBranchIcon,
GitPullRequestIcon,
InboxIcon,
ReviewsIcon,
} from "@quickhub/icons";
import { cn } from "@quickhub/ui/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
type ComponentType,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading";
import { PullRequestRow } from "#/components/pulls/pull-request-row";
import { githubMyPullsQueryOptions } from "#/lib/github.query";
import type { PullSummary } from "#/lib/github.types";
import type { MyPullsResult, PullSummary } from "#/lib/github.types";
import { useHasMounted } from "#/lib/use-has-mounted";

export const Route = createFileRoute("/_protected/pulls")({
Expand All @@ -11,33 +27,97 @@ export const Route = createFileRoute("/_protected/pulls")({

function PullRequestsPage() {
const { user } = Route.useRouteContext();
const scope = { userId: user.id };
const hasMounted = useHasMounted();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const query = useQuery({
...githubMyPullsQueryOptions({ userId: user.id }),
...githubMyPullsQueryOptions(scope),
enabled: hasMounted,
});

if (query.error) throw query.error;
if (query.data) {
const data = query.data;
const groups: PullGroupData[] = [
{
id: "review-requested",
title: "Review requested",
icon: ReviewsIcon,
pulls: data.reviewRequested,
},
{
id: "assigned",
title: "Assigned",
icon: InboxIcon,
pulls: data.assigned,
},
{
id: "authored",
title: "Authored",
icon: GitPullRequestIcon,
pulls: data.authored,
},
{
id: "mentioned",
title: "Mentioned",
icon: CommentIcon,
pulls: data.mentioned,
},
{
id: "involved",
title: "Involved",
icon: GitBranchIcon,
pulls: data.involved,
},
];
const totalPulls = groups.reduce(
(sum, group) => sum + group.pulls.length,
0,
);

return (
<div className="flex h-full flex-col gap-6 overflow-auto p-6">
<header className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
Cached pull request groups
</p>
<h1 className="text-2xl font-semibold tracking-tight">
Pull Requests
</h1>
</header>

<div className="grid gap-4 xl:grid-cols-2">
<PullGroup title="Review requested" pulls={data.reviewRequested} />
<PullGroup title="Assigned" pulls={data.assigned} />
<PullGroup title="Authored" pulls={data.authored} />
<PullGroup title="Mentioned" pulls={data.mentioned} />
<PullGroup title="Involved" pulls={data.involved} />
<div ref={scrollContainerRef} className="h-full overflow-auto py-10">
<div className="mx-auto grid max-w-7xl gap-14 px-6 xl:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)]">
<aside className="flex h-fit flex-col gap-5 xl:sticky xl:top-0">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
Pull Requests
</h1>
<p className="text-sm text-muted-foreground">
<span className="tabular-nums">{totalPulls}</span> open pulls
across your queues
</p>
</div>

<nav
className="flex flex-col gap-2"
aria-label="Pull request groups"
>
{groups.map((group) => (
<PullMetricCard
key={group.id}
href={`#${group.id}`}
icon={group.icon}
label={group.title}
value={group.pulls.length}
/>
))}
</nav>
</aside>

<div className="flex flex-col gap-2">
{groups.map((group) => (
<PullGroup
key={group.id}
id={group.id}
title={group.title}
icon={group.icon}
pulls={group.pulls}
scope={scope}
scrollContainerRef={scrollContainerRef}
/>
))}
</div>
</div>
</div>
);
Expand All @@ -49,28 +129,135 @@ function PullRequestsPage() {
return null;
}

function PullGroup({ title, pulls }: { title: string; pulls: PullSummary[] }) {
type PullGroupData = {
id: string;
title: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
pulls: MyPullsResult[keyof MyPullsResult];
};

const PULL_GROUP_STICKY_TOP = -32;

function PullMetricCard({
href,
icon: Icon,
label,
value,
}: {
href: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
label: string;
value: number;
}) {
const content = (
<>
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-muted-foreground">
<Icon size={15} strokeWidth={1.9} />
</div>
<p className="truncate text-sm font-medium">{label}</p>
</div>
<p className="font-semibold tabular-nums leading-tight">{value}</p>
</>
);

if (value === 0) {
return (
<div
aria-disabled="true"
className="flex items-center justify-between gap-4 rounded-xl bg-surface-1 px-3.5 py-3 opacity-70"
>
{content}
</div>
);
}

return (
<section className="rounded-2xl border bg-background/70 p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-medium">{title}</h2>
<span className="text-sm text-muted-foreground">{pulls.length}</span>
<a
href={href}
className="flex items-center justify-between gap-4 rounded-xl bg-surface-1 px-3.5 py-3 transition-colors hover:bg-surface-2"
>
{content}
</a>
);
}

function PullGroup({
id,
title,
icon: Icon,
pulls,
scope,
scrollContainerRef,
}: {
id: string;
title: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
pulls: PullSummary[];
scope: { userId: string };
scrollContainerRef: RefObject<HTMLDivElement | null>;
}) {
const sectionRef = useRef<HTMLElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [isStickyActive, setIsStickyActive] = useState(false);

useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const section = sectionRef.current;
const header = headerRef.current;

if (!scrollContainer || !section || !header) {
return;
}

const updateStickyState = () => {
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const sectionRect = section.getBoundingClientRect();
const stickyTop = scrollContainerRect.top + PULL_GROUP_STICKY_TOP;
const headerHeight = header.offsetHeight;
const isStuck =
sectionRect.top <= stickyTop &&
sectionRect.bottom > stickyTop + headerHeight;

setIsStickyActive((current) => (current === isStuck ? current : isStuck));
};

updateStickyState();
scrollContainer.addEventListener("scroll", updateStickyState, {
passive: true,
});
window.addEventListener("resize", updateStickyState);

return () => {
scrollContainer.removeEventListener("scroll", updateStickyState);
window.removeEventListener("resize", updateStickyState);
};
}, [scrollContainerRef]);

return (
<section ref={sectionRef} id={id}>
<div
ref={headerRef}
className={cn(
"sticky -top-8 z-10 flex items-center justify-between gap-3 rounded-lg bg-surface-1 px-3 py-2 transition-shadow",
isStickyActive && "shadow-lg",
pulls.length === 0 && "opacity-70",
)}
>
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-muted-foreground">
<Icon size={15} strokeWidth={1.9} />
</div>
<h2 className="truncate text-sm font-medium">{title}</h2>
</div>
<span className="text-sm tabular-nums text-muted-foreground">
{pulls.length}
</span>
</div>
{pulls.length === 0 ? (
<p className="text-sm text-muted-foreground">
No pull requests in this slice.
</p>
) : (
<div className="space-y-3">
{pulls.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{pulls.map((pull) => (
<div key={pull.id} className="rounded-xl border px-3 py-2">
<p className="text-sm font-medium">
#{pull.number} {pull.title}
</p>
<p className="text-sm text-muted-foreground">
{pull.repository.fullName}
</p>
</div>
<PullRequestRow key={pull.id} pr={pull} scope={scope} />
))}
</div>
)}
Expand Down