From 2beb39366bef6ccd7de119a256e7e90e3b07195d Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 15:49:41 -0400 Subject: [PATCH] Add repo-scoped issues and pull request list pages Adds /$owner/$repo/issues and /$owner/$repo/pulls pages with URL-driven filtering, sorting, search, and pagination via nuqs. Reuses the existing FilterBar with a new useRepoListFilters hook that keeps all state in the URL. Includes keepPreviousData for smooth page transitions and throttled URL params to reduce API calls. --- .../dashboard/src/components/filters/index.ts | 19 +- .../src/components/filters/issue-filters.ts | 63 +++++ .../src/components/filters/pull-filters.ts | 65 ++++++ .../filters/use-repo-list-filters.ts | 216 ++++++++++++++++++ apps/dashboard/src/components/pagination.tsx | 56 +++++ apps/dashboard/src/routeTree.gen.ts | 69 +++++- .../routes/_protected/$owner/$repo/issues.tsx | 158 +++++++++++++ .../routes/_protected/$owner/$repo/pulls.tsx | 159 +++++++++++++ 8 files changed, 796 insertions(+), 9 deletions(-) create mode 100644 apps/dashboard/src/components/filters/use-repo-list-filters.ts create mode 100644 apps/dashboard/src/components/pagination.tsx create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/issues.tsx create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/pulls.tsx diff --git a/apps/dashboard/src/components/filters/index.ts b/apps/dashboard/src/components/filters/index.ts index bf6370c..b50a95d 100644 --- a/apps/dashboard/src/components/filters/index.ts +++ b/apps/dashboard/src/components/filters/index.ts @@ -1,7 +1,22 @@ export { FilterBar } from "./filter-bar"; export type { SerializedFilterStore } from "./filter-cookie"; export { getFilterCookie } from "./filter-cookie"; -export { issueFilterDefs, issueSortOptions } from "./issue-filters"; -export { pullFilterDefs, pullSortOptions } from "./pull-filters"; +export { + issueFilterDefs, + issueSortOptions, + repoIssueFilterDefs, +} from "./issue-filters"; +export { + pullFilterDefs, + pullSortOptions, + repoPullFilterDefs, +} from "./pull-filters"; export type { FilterableItem, ListFilterState } from "./use-list-filters"; export { applyFilters, useListFilters } from "./use-list-filters"; +export { + applyRepoFilters, + getFilterValues, + parseFilterString, + repoListUrlParsers, + useRepoListFilters, +} from "./use-repo-list-filters"; diff --git a/apps/dashboard/src/components/filters/issue-filters.ts b/apps/dashboard/src/components/filters/issue-filters.ts index 6a54bd8..64abe44 100644 --- a/apps/dashboard/src/components/filters/issue-filters.ts +++ b/apps/dashboard/src/components/filters/issue-filters.ts @@ -111,6 +111,69 @@ export const issueFilterDefs: FilterDefinition[] = [ }, ]; +/** Filter defs for repo-scoped issue lists — static options, no repository filter. */ +export const repoIssueFilterDefs: FilterDefinition[] = [ + { + id: "status", + label: "Status", + icon: CircleIcon, + extractOptions: () => { + const colorMap: Record = { + open: "text-green-500", + completed: "text-purple-500", + not_planned: "text-muted-foreground", + }; + return [ + { value: "open", label: "Open" }, + { value: "completed", label: "Closed (completed)" }, + { value: "not_planned", label: "Closed (not planned)" }, + ].map((s) => ({ + value: s.value, + label: s.label, + icon: createElement(IssuesIcon, { + size: 14, + className: colorMap[s.value], + }), + })); + }, + match: (item, values) => { + const issue = asIssue(item); + if (issue.state === "closed") { + return issue.stateReason === "not_planned" + ? values.has("not_planned") + : values.has("completed"); + } + return values.has("open"); + }, + }, + { + id: "author", + label: "Author", + icon: UserCircleIcon, + extractOptions: (items) => { + const authors = new Map(); + for (const item of items) { + if (item.author && !authors.has(item.author.login)) { + authors.set(item.author.login, item.author); + } + } + return [...authors.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([login, author]) => ({ + value: login, + label: login, + icon: createElement("img", { + src: author.avatarUrl, + alt: login, + className: "size-4 rounded-full", + }), + })); + }, + match: (item, values) => + item.author ? values.has(item.author.login) : false, + }, +]; + export const issueSortOptions: SortOption[] = [ { id: "updated", diff --git a/apps/dashboard/src/components/filters/pull-filters.ts b/apps/dashboard/src/components/filters/pull-filters.ts index ef10e25..116e7d2 100644 --- a/apps/dashboard/src/components/filters/pull-filters.ts +++ b/apps/dashboard/src/components/filters/pull-filters.ts @@ -116,6 +116,71 @@ export const pullFilterDefs: FilterDefinition[] = [ }, ]; +/** Filter defs for repo-scoped pull lists — static options, no repository filter. */ +export const repoPullFilterDefs: FilterDefinition[] = [ + { + id: "status", + label: "Status", + icon: CircleIcon, + extractOptions: () => { + const colorMap: Record = { + open: "text-green-500", + draft: "text-muted-foreground", + merged: "text-purple-500", + closed: "text-red-500", + }; + return ( + [ + { value: "open", label: "Open", icon: GitPullRequestIcon }, + { value: "draft", label: "Draft", icon: GitPullRequestDraftIcon }, + { value: "merged", label: "Merged", icon: GitMergeIcon }, + { value: "closed", label: "Closed", icon: GitPullRequestClosedIcon }, + ] as const + ).map((s) => ({ + value: s.value, + label: s.label, + icon: createElement(s.icon, { + size: 14, + className: colorMap[s.value], + }), + })); + }, + match: (item, values) => { + const pull = asPull(item); + if (pull.mergedAt) return values.has("merged"); + if (pull.state === "closed") return values.has("closed"); + if (pull.isDraft) return values.has("draft"); + return values.has("open"); + }, + }, + { + id: "author", + label: "Author", + icon: UserCircleIcon, + extractOptions: (items) => { + const authors = new Map(); + for (const item of items) { + if (item.author && !authors.has(item.author.login)) { + authors.set(item.author.login, item.author); + } + } + return [...authors.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([login, author]) => ({ + value: login, + label: login, + icon: createElement("img", { + src: author.avatarUrl, + alt: login, + className: "size-4 rounded-full", + }), + })); + }, + match: (item, values) => + item.author ? values.has(item.author.login) : false, + }, +]; + export const pullSortOptions: SortOption[] = [ { id: "updated", diff --git a/apps/dashboard/src/components/filters/use-repo-list-filters.ts b/apps/dashboard/src/components/filters/use-repo-list-filters.ts new file mode 100644 index 0000000..8e91977 --- /dev/null +++ b/apps/dashboard/src/components/filters/use-repo-list-filters.ts @@ -0,0 +1,216 @@ +import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import type { + ActiveFilter, + FilterableItem, + FilterDefinition, + FilterOption, + SortOption, +} from "./use-list-filters"; + +// ── URL parsers ────────────────────────────────────────────────────────── + +export const repoListUrlParsers = { + q: parseAsString + .withDefault("") + .withOptions({ history: "replace", throttleMs: 300 }), + sort: parseAsString.withOptions({ history: "push", throttleMs: 300 }), + page: parseAsInteger + .withDefault(1) + .withOptions({ history: "push", throttleMs: 300 }), + filters: parseAsString.withOptions({ history: "push", throttleMs: 300 }), +}; + +// ── Compact filter serialization ───────────────────────────────────────── +// URL format: "state:open,closed|author:user1,user2" + +export function parseFilterString(raw: string | null): ActiveFilter[] { + if (!raw) return []; + return raw + .split("|") + .filter(Boolean) + .map((segment) => { + const colonIdx = segment.indexOf(":"); + if (colonIdx === -1) return null; + const fieldId = segment.slice(0, colonIdx); + const values = segment + .slice(colonIdx + 1) + .split(",") + .filter(Boolean); + return values.length > 0 ? { fieldId, values: new Set(values) } : null; + }) + .filter((f): f is ActiveFilter => f !== null); +} + +function serializeFilters(filters: ActiveFilter[]): string | null { + const valid = filters.filter((f) => f.values.size > 0); + if (valid.length === 0) return null; + return valid.map((f) => `${f.fieldId}:${[...f.values].join(",")}`).join("|"); +} + +// ── Hook ───────────────────────────────────────────────────────────────── + +export function useRepoListFilters({ + filterDefs, + sortOptions, + defaultSortId, + items, +}: { + filterDefs: FilterDefinition[]; + sortOptions: SortOption[]; + defaultSortId: string; + /** Loaded items for the current page (used by extractOptions for data-driven filters like author). */ + items: T[]; +}) { + const [params, setParams] = useQueryStates(repoListUrlParsers); + + const activeFilters = useMemo( + () => parseFilterString(params.filters), + [params.filters], + ); + + const sortId = + sortOptions.some((o) => o.id === params.sort) && params.sort + ? params.sort + : defaultSortId; + + const availableOptions = useMemo(() => { + const map = new Map(); + for (const def of filterDefs) { + map.set(def.id, def.extractOptions(items)); + } + return map; + }, [items, filterDefs]); + + const setSearchQuery = useCallback( + (q: string) => { + void setParams({ q, page: 1 }); + }, + [setParams], + ); + + const setSortId = useCallback( + (sort: string) => { + void setParams({ sort, page: 1 }); + }, + [setParams], + ); + + const addFilter = useCallback( + (fieldId: string, value: string) => { + const current = parseFilterString(params.filters); + const existing = current.find((f) => f.fieldId === fieldId); + let next: ActiveFilter[]; + if (existing) { + next = current.map((f) => + f.fieldId === fieldId + ? { ...f, values: new Set([...f.values, value]) } + : f, + ); + } else { + next = [...current, { fieldId, values: new Set([value]) }]; + } + void setParams({ filters: serializeFilters(next), page: 1 }); + }, + [params.filters, setParams], + ); + + const removeFilterValue = useCallback( + (fieldId: string, value: string) => { + const current = parseFilterString(params.filters); + const next = current + .map((f) => { + if (f.fieldId !== fieldId) return f; + const values = new Set(f.values); + values.delete(value); + return { ...f, values }; + }) + .filter((f) => f.values.size > 0); + void setParams({ filters: serializeFilters(next), page: 1 }); + }, + [params.filters, setParams], + ); + + const removeFilter = useCallback( + (fieldId: string) => { + const current = parseFilterString(params.filters); + const next = current.filter((f) => f.fieldId !== fieldId); + void setParams({ filters: serializeFilters(next), page: 1 }); + }, + [params.filters, setParams], + ); + + const clearAllFilters = useCallback(() => { + void setParams({ q: "", filters: null, page: 1 }); + }, [setParams]); + + const hasActiveFilters = activeFilters.length > 0 || params.q.length > 0; + + const setPage = useCallback( + (page: number) => { + void setParams({ page }); + }, + [setParams], + ); + + return { + // ListFilterState-compatible shape (reuses FilterBar as-is) + searchQuery: params.q, + setSearchQuery, + activeFilters, + sortId, + setSortId, + availableOptions, + addFilter, + removeFilterValue, + removeFilter, + clearAllFilters, + hasActiveFilters, + sortOptions, + filterDefs, + // Pagination (repo-list specific) + page: params.page, + setPage, + }; +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** Read selected values for a specific filter field. */ +export function getFilterValues( + activeFilters: ActiveFilter[], + fieldId: string, +): Set { + return activeFilters.find((f) => f.fieldId === fieldId)?.values ?? new Set(); +} + +/** Apply client-side filters (search, match, sort) to items from the current page. */ +export function applyRepoFilters( + items: T[], + state: ReturnType>, +): T[] { + const query = state.searchQuery.toLowerCase().trim(); + const sortFn = + state.sortOptions.find((s) => s.id === state.sortId)?.compare ?? + state.sortOptions[0].compare; + + let result: T[] = items; + + if (query) { + result = result.filter( + (item) => + item.title.toLowerCase().includes(query) || + item.repository.fullName.toLowerCase().includes(query) || + (item.author?.login.toLowerCase().includes(query) ?? false), + ); + } + + for (const filter of state.activeFilters) { + if (filter.values.size === 0) continue; + const def = state.filterDefs.find((d) => d.id === filter.fieldId); + if (!def) continue; + result = result.filter((item) => def.match(item, filter.values)); + } + + return [...result].sort(sortFn); +} diff --git a/apps/dashboard/src/components/pagination.tsx b/apps/dashboard/src/components/pagination.tsx new file mode 100644 index 0000000..2b0e72b --- /dev/null +++ b/apps/dashboard/src/components/pagination.tsx @@ -0,0 +1,56 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import { memo } from "react"; + +export const Pagination = memo(function Pagination({ + page, + hasNextPage, + onPageChange, +}: { + page: number; + hasNextPage: boolean; + onPageChange: (page: number) => void; +}) { + if (page === 1 && !hasNextPage) return null; + + return ( + + ); +}); diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 237a541..8ec803c 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -28,6 +28,8 @@ import { Route as ProtectedSettingsShortcutsRouteImport } from './routes/_protec import { Route as ProtectedOwnerRepoIndexRouteImport } from './routes/_protected/$owner/$repo/index' import { Route as ApiGithubAppCallbackRouteImport } from './routes/api/github/app/callback' import { Route as ApiGithubAppAuthorizeRouteImport } from './routes/api/github/app/authorize' +import { Route as ProtectedOwnerRepoPullsRouteImport } from './routes/_protected/$owner/$repo/pulls' +import { Route as ProtectedOwnerRepoIssuesRouteImport } from './routes/_protected/$owner/$repo/issues' import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_protected/$owner/$repo/review.$pullId' import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' @@ -127,6 +129,17 @@ const ApiGithubAppAuthorizeRoute = ApiGithubAppAuthorizeRouteImport.update({ path: '/api/github/app/authorize', getParentRoute: () => rootRouteImport, } as any) +const ProtectedOwnerRepoPullsRoute = ProtectedOwnerRepoPullsRouteImport.update({ + id: '/$owner/$repo/pulls', + path: '/$owner/$repo/pulls', + getParentRoute: () => ProtectedRoute, +} as any) +const ProtectedOwnerRepoIssuesRoute = + ProtectedOwnerRepoIssuesRouteImport.update({ + id: '/$owner/$repo/issues', + path: '/$owner/$repo/issues', + getParentRoute: () => ProtectedRoute, + } as any) const ProtectedOwnerRepoReviewPullIdRoute = ProtectedOwnerRepoReviewPullIdRouteImport.update({ id: '/$owner/$repo/review/$pullId', @@ -141,9 +154,9 @@ const ProtectedOwnerRepoPullPullIdRoute = } as any) const ProtectedOwnerRepoIssuesIssueIdRoute = ProtectedOwnerRepoIssuesIssueIdRouteImport.update({ - id: '/$owner/$repo/issues/$issueId', - path: '/$owner/$repo/issues/$issueId', - getParentRoute: () => ProtectedRoute, + id: '/$issueId', + path: '/$issueId', + getParentRoute: () => ProtectedOwnerRepoIssuesRoute, } as any) export interface FileRoutesByFullPath { @@ -162,6 +175,8 @@ export interface FileRoutesByFullPath { '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/$owner/': typeof ProtectedOwnerIndexRoute '/settings/': typeof ProtectedSettingsIndexRoute + '/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRouteWithChildren + '/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute @@ -184,6 +199,8 @@ export interface FileRoutesByTo { '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/$owner': typeof ProtectedOwnerIndexRoute '/settings': typeof ProtectedSettingsIndexRoute + '/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRouteWithChildren + '/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute @@ -209,6 +226,8 @@ export interface FileRoutesById { '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/_protected/$owner/': typeof ProtectedOwnerIndexRoute '/_protected/settings/': typeof ProtectedSettingsIndexRoute + '/_protected/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesRouteWithChildren + '/_protected/$owner/$repo/pulls': typeof ProtectedOwnerRepoPullsRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute @@ -234,6 +253,8 @@ export interface FileRouteTypes { | '/api/webhooks/github' | '/$owner/' | '/settings/' + | '/$owner/$repo/issues' + | '/$owner/$repo/pulls' | '/api/github/app/authorize' | '/api/github/app/callback' | '/$owner/$repo/' @@ -256,6 +277,8 @@ export interface FileRouteTypes { | '/api/webhooks/github' | '/$owner' | '/settings' + | '/$owner/$repo/issues' + | '/$owner/$repo/pulls' | '/api/github/app/authorize' | '/api/github/app/callback' | '/$owner/$repo' @@ -280,6 +303,8 @@ export interface FileRouteTypes { | '/api/webhooks/github' | '/_protected/$owner/' | '/_protected/settings/' + | '/_protected/$owner/$repo/issues' + | '/_protected/$owner/$repo/pulls' | '/api/github/app/authorize' | '/api/github/app/callback' | '/_protected/$owner/$repo/' @@ -436,6 +461,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGithubAppAuthorizeRouteImport parentRoute: typeof rootRouteImport } + '/_protected/$owner/$repo/pulls': { + id: '/_protected/$owner/$repo/pulls' + path: '/$owner/$repo/pulls' + fullPath: '/$owner/$repo/pulls' + preLoaderRoute: typeof ProtectedOwnerRepoPullsRouteImport + parentRoute: typeof ProtectedRoute + } + '/_protected/$owner/$repo/issues': { + id: '/_protected/$owner/$repo/issues' + path: '/$owner/$repo/issues' + fullPath: '/$owner/$repo/issues' + preLoaderRoute: typeof ProtectedOwnerRepoIssuesRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/$owner/$repo/review/$pullId': { id: '/_protected/$owner/$repo/review/$pullId' path: '/$owner/$repo/review/$pullId' @@ -452,10 +491,10 @@ declare module '@tanstack/react-router' { } '/_protected/$owner/$repo/issues/$issueId': { id: '/_protected/$owner/$repo/issues/$issueId' - path: '/$owner/$repo/issues/$issueId' + path: '/$issueId' fullPath: '/$owner/$repo/issues/$issueId' preLoaderRoute: typeof ProtectedOwnerRepoIssuesIssueIdRouteImport - parentRoute: typeof ProtectedRoute + parentRoute: typeof ProtectedOwnerRepoIssuesRoute } } } @@ -473,6 +512,20 @@ const ProtectedSettingsRouteChildren: ProtectedSettingsRouteChildren = { const ProtectedSettingsRouteWithChildren = ProtectedSettingsRoute._addFileChildren(ProtectedSettingsRouteChildren) +interface ProtectedOwnerRepoIssuesRouteChildren { + ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute +} + +const ProtectedOwnerRepoIssuesRouteChildren: ProtectedOwnerRepoIssuesRouteChildren = + { + ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, + } + +const ProtectedOwnerRepoIssuesRouteWithChildren = + ProtectedOwnerRepoIssuesRoute._addFileChildren( + ProtectedOwnerRepoIssuesRouteChildren, + ) + interface ProtectedRouteChildren { ProtectedIssuesRoute: typeof ProtectedIssuesRoute ProtectedPullsRoute: typeof ProtectedPullsRoute @@ -480,8 +533,9 @@ interface ProtectedRouteChildren { ProtectedSettingsRoute: typeof ProtectedSettingsRouteWithChildren ProtectedIndexRoute: typeof ProtectedIndexRoute ProtectedOwnerIndexRoute: typeof ProtectedOwnerIndexRoute + ProtectedOwnerRepoIssuesRoute: typeof ProtectedOwnerRepoIssuesRouteWithChildren + ProtectedOwnerRepoPullsRoute: typeof ProtectedOwnerRepoPullsRoute ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute - ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute ProtectedOwnerRepoReviewPullIdRoute: typeof ProtectedOwnerRepoReviewPullIdRoute } @@ -493,8 +547,9 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedSettingsRoute: ProtectedSettingsRouteWithChildren, ProtectedIndexRoute: ProtectedIndexRoute, ProtectedOwnerIndexRoute: ProtectedOwnerIndexRoute, + ProtectedOwnerRepoIssuesRoute: ProtectedOwnerRepoIssuesRouteWithChildren, + ProtectedOwnerRepoPullsRoute: ProtectedOwnerRepoPullsRoute, ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, - ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, ProtectedOwnerRepoReviewPullIdRoute: ProtectedOwnerRepoReviewPullIdRoute, } diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.tsx new file mode 100644 index 0000000..5e72deb --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.tsx @@ -0,0 +1,158 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useQueryStates } from "nuqs"; +import { useMemo } from "react"; +import { + applyRepoFilters, + FilterBar, + getFilterValues, + issueSortOptions, + parseFilterString, + repoIssueFilterDefs, + repoListUrlParsers, + useRepoListFilters, +} from "#/components/filters"; +import { IssueRow } from "#/components/issues/issue-row"; +import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; +import { Pagination } from "#/components/pagination"; +import { + githubIssuesFromRepoQueryOptions, + githubRepoOverviewQueryOptions, +} from "#/lib/github.query"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +const PER_PAGE = 30; + +export const Route = createFileRoute("/_protected/$owner/$repo/issues")({ + ssr: false, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle(`Issues · ${params.owner}/${params.repo}`), + description: `Issues for ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: RepoIssuesPage, +}); + +/** + * Map the status filter pill values to the GitHub API `state` parameter. + * No status selected → default to "open". + */ +function deriveApiState(statusValues: Set): "open" | "closed" | "all" { + if (statusValues.size === 0) return "open"; + const hasOpen = statusValues.has("open"); + const hasClosed = + statusValues.has("completed") || statusValues.has("not_planned"); + if (hasOpen && hasClosed) return "all"; + if (hasClosed) return "closed"; + return "open"; +} + +function RepoIssuesPage() { + const { user } = Route.useRouteContext(); + const { owner, repo } = Route.useParams(); + const scope = { userId: user.id }; + const hasMounted = useHasMounted(); + + // 1. Read URL params directly — needed before the query + const [urlParams] = useQueryStates(repoListUrlParsers); + const urlFilters = useMemo( + () => parseFilterString(urlParams.filters), + [urlParams.filters], + ); + const statusValues = getFilterValues(urlFilters, "status"); + const apiState = deriveApiState(statusValues); + + // 2. Fetch data using URL-derived params + const overviewQuery = useQuery({ + ...githubRepoOverviewQueryOptions(scope, { owner, repo }), + enabled: hasMounted, + }); + + const query = useQuery({ + ...githubIssuesFromRepoQueryOptions(scope, { + owner, + repo, + state: apiState, + page: urlParams.page, + perPage: PER_PAGE, + }), + enabled: hasMounted, + placeholderData: keepPreviousData, + }); + + const issues = useMemo(() => query.data ?? [], [query.data]); + const hasNextPage = issues.length === PER_PAGE; + + // 3. Full filter hook with loaded items — populates author options + const filterState = useRepoListFilters({ + filterDefs: repoIssueFilterDefs, + sortOptions: issueSortOptions, + defaultSortId: "updated", + items: issues, + }); + + const filtered = useMemo( + () => applyRepoFilters(issues, filterState), + [issues, filterState], + ); + + const totalLabel = overviewQuery.data?.openIssueCount; + + return ( +
+
+
+

Issues

+

+ {totalLabel != null ? ( + {totalLabel} open · + ) : null} + + {owner}/{repo} + +

+
+ + + + {query.isLoading ? ( +
+ +
+ ) : ( +
+ {filtered.length === 0 && ( +

+ No issues found. +

+ )} + {filtered.map((issue) => ( +
+ +
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pulls.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pulls.tsx new file mode 100644 index 0000000..a842d9b --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pulls.tsx @@ -0,0 +1,159 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useQueryStates } from "nuqs"; +import { useMemo } from "react"; +import { + applyRepoFilters, + FilterBar, + getFilterValues, + parseFilterString, + pullSortOptions, + repoListUrlParsers, + repoPullFilterDefs, + useRepoListFilters, +} from "#/components/filters"; +import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; +import { Pagination } from "#/components/pagination"; +import { PullRequestRow } from "#/components/pulls/pull-request-row"; +import { + githubPullsFromRepoQueryOptions, + githubRepoOverviewQueryOptions, +} from "#/lib/github.query"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +const PER_PAGE = 30; + +export const Route = createFileRoute("/_protected/$owner/$repo/pulls")({ + ssr: false, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle(`Pull requests · ${params.owner}/${params.repo}`), + description: `Pull requests for ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: RepoPullsPage, +}); + +/** + * Map the status filter pill values to the GitHub API `state` parameter. + * No status selected → default to "open". + */ +function deriveApiState(statusValues: Set): "open" | "closed" | "all" { + if (statusValues.size === 0) return "open"; + const hasOpen = statusValues.has("open") || statusValues.has("draft"); + const hasClosed = statusValues.has("closed") || statusValues.has("merged"); + if (hasOpen && hasClosed) return "all"; + if (hasClosed) return "closed"; + return "open"; +} + +function RepoPullsPage() { + const { user } = Route.useRouteContext(); + const { owner, repo } = Route.useParams(); + const scope = { userId: user.id }; + const hasMounted = useHasMounted(); + + // 1. Read URL params directly — needed before the query + const [urlParams] = useQueryStates(repoListUrlParsers); + const urlFilters = useMemo( + () => parseFilterString(urlParams.filters), + [urlParams.filters], + ); + const statusValues = getFilterValues(urlFilters, "status"); + const apiState = deriveApiState(statusValues); + + // 2. Fetch data using URL-derived params + const overviewQuery = useQuery({ + ...githubRepoOverviewQueryOptions(scope, { owner, repo }), + enabled: hasMounted, + }); + + const query = useQuery({ + ...githubPullsFromRepoQueryOptions(scope, { + owner, + repo, + state: apiState, + page: urlParams.page, + perPage: PER_PAGE, + }), + enabled: hasMounted, + placeholderData: keepPreviousData, + }); + + const pulls = useMemo(() => query.data ?? [], [query.data]); + const hasNextPage = pulls.length === PER_PAGE; + + // 3. Full filter hook with loaded items — populates author options + const filterState = useRepoListFilters({ + filterDefs: repoPullFilterDefs, + sortOptions: pullSortOptions, + defaultSortId: "updated", + items: pulls, + }); + + const filtered = useMemo( + () => applyRepoFilters(pulls, filterState), + [pulls, filterState], + ); + + const totalLabel = overviewQuery.data?.openPullCount; + + return ( +
+
+
+

+ Pull Requests +

+

+ {totalLabel != null ? ( + {totalLabel} open · + ) : null} + + {owner}/{repo} + +

+
+ + + + {query.isLoading ? ( +
+ +
+ ) : ( +
+ {filtered.length === 0 && ( +

+ No pull requests found. +

+ )} + {filtered.map((pr) => ( +
+ +
+ ))} +
+ )} + + +
+
+ ); +}