diff --git a/apps/code-infra-dashboard/app/(dashboard)/reactions/page.tsx b/apps/code-infra-dashboard/app/(dashboard)/reactions/page.tsx new file mode 100644 index 000000000..eb30e346a --- /dev/null +++ b/apps/code-infra-dashboard/app/(dashboard)/reactions/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import type { Metadata } from 'next'; +import Reactions from '@/views/Reactions'; + +export const metadata: Metadata = { title: 'GitHub Reactions' }; + +export default function ReactionsPage() { + return ; +} diff --git a/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts b/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts index 75744e514..a35344559 100644 --- a/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts +++ b/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts @@ -86,13 +86,38 @@ export interface UseSearchParamsStateOptions { * // Functional updates * setParams(prev => ({ page: prev.page + 1 })); */ +const EMPTY_SEARCH_PARAMS = new URLSearchParams(); +const emptySubscribe = () => () => {}; +const getHydratedSnapshot = () => true; +const getServerSnapshot = () => false; + +/** + * Return the real `useSearchParams()` result only after hydration. During SSR + * and the first client hydration render, returns an empty `URLSearchParams`. + * + * This avoids hydration mismatches when the app is rendered with + * `dynamic = 'force-static'`: Next.js silently returns empty search params + * during the static prerender but returns the real URL params on the client, + * which normally causes SSR/client divergence for any component that reads + * from the search params. + */ +function useHydrationSafeSearchParams() { + const hydrated = React.useSyncExternalStore( + emptySubscribe, + getHydratedSnapshot, + getServerSnapshot, + ); + const real = useSearchParams(); + return hydrated ? real : EMPTY_SEARCH_PARAMS; +} + export function useSearchParamsState( config: { [K in keyof C]: ParamConfig }, defaultOptions?: UseSearchParamsStateOptions, ): [C, SetState] { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams(); + const searchParams = useHydrationSafeSearchParams(); // Track previous state to preserve value references const prevStateRef = React.useRef(null); diff --git a/apps/code-infra-dashboard/src/utils/github.ts b/apps/code-infra-dashboard/src/utils/github.ts index 76961d665..f2e96c3b1 100644 --- a/apps/code-infra-dashboard/src/utils/github.ts +++ b/apps/code-infra-dashboard/src/utils/github.ts @@ -12,3 +12,36 @@ export function parseRepo(input: string): { owner: string; repo: string } { } return { owner, repo }; } + +export interface IssueReactionTarget { + owner: string; + repo: string; + number: number; +} + +const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?$/; + +/** + * Parse a GitHub issue or pull request URL. + * Returns null if the URL is not a recognized issue/PR resource (comment URLs are rejected). + */ +export function parseIssueUrl(input: string): IssueReactionTarget | null { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + return null; + } + + if (url.hostname !== 'github.com' || url.hash) { + return null; + } + + const pathMatch = ISSUE_PATH_RE.exec(url.pathname); + if (!pathMatch) { + return null; + } + + const [, owner, repo, numberStr] = pathMatch; + return { owner, repo, number: Number(numberStr) }; +} diff --git a/apps/code-infra-dashboard/src/views/Landing.tsx b/apps/code-infra-dashboard/src/views/Landing.tsx index c366dd419..01b345a96 100644 --- a/apps/code-infra-dashboard/src/views/Landing.tsx +++ b/apps/code-infra-dashboard/src/views/Landing.tsx @@ -11,6 +11,7 @@ import Box from '@mui/material/Box'; import NextLink from 'next/link'; import TrendingUpIcon from '@mui/icons-material/TrendingUp'; import GitHubIcon from '@mui/icons-material/GitHub'; +import ThumbsUpDownIcon from '@mui/icons-material/ThumbsUpDown'; import BuildIcon from '@mui/icons-material/Build'; import CompareIcon from '@mui/icons-material/Compare'; import AssessmentIcon from '@mui/icons-material/Assessment'; @@ -73,6 +74,12 @@ const tools: Tool[] = [ icon: , path: '/github-triage', }, + { + name: 'GitHub Reactions', + description: 'List every emoji reaction on a public GitHub issue or pull request', + icon: , + path: '/reactions', + }, ]; export default function Landing() { diff --git a/apps/code-infra-dashboard/src/views/Reactions.tsx b/apps/code-infra-dashboard/src/views/Reactions.tsx new file mode 100644 index 000000000..840215fb4 --- /dev/null +++ b/apps/code-infra-dashboard/src/views/Reactions.tsx @@ -0,0 +1,240 @@ +'use client'; + +import * as React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import Alert from '@mui/material/Alert'; +import NextLink from 'next/link'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Link from '@mui/material/Link'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, + type GridColDef, +} from '@mui/x-data-grid-premium'; +import Heading from '../components/Heading'; +import ErrorDisplay from '../components/ErrorDisplay'; +import { useSearchParamsState } from '../hooks/useSearchParamsState'; +import { octokit, parseIssueUrl, type IssueReactionTarget } from '../utils/github'; + +const EXAMPLES = [ + { label: 'mui-design-kits#10', url: 'https://github.com/mui/mui-design-kits/issues/10' }, + { label: 'mui-design-kits#111', url: 'https://github.com/mui/mui-design-kits/issues/111' }, +].map((example) => ({ + ...example, + href: `/reactions?${new URLSearchParams({ url: example.url })}`, +})); + +const EMOJI: Record = { + '+1': '👍', + '-1': '👎', + laugh: '😄', + confused: '😕', + heart: '❤️', + hooray: '🎉', + rocket: '🚀', + eyes: '👀', +}; + +interface ReactionRow { + id: number; + content: string; + user: string; + userUrl: string; +} + +interface ReactionsResult { + rows: ReactionRow[]; + truncated: boolean; +} + +const MAX_PAGES = 3; +const PAGE_SIZE = 100; + +async function fetchReactions( + target: IssueReactionTarget, + unbounded: boolean, +): Promise { + const iterator = octokit.paginate.iterator(octokit.rest.reactions.listForIssue, { + owner: target.owner, + repo: target.repo, + issue_number: target.number, + per_page: PAGE_SIZE, + }); + + const rows: ReactionRow[] = []; + let pages = 0; + let truncated = false; + for await (const response of iterator) { + pages += 1; + for (const reaction of response.data) { + rows.push({ + id: reaction.id, + content: reaction.content, + user: reaction.user?.login ?? '(unknown)', + userUrl: reaction.user?.html_url ?? '', + }); + } + if (!unbounded && pages >= MAX_PAGES && response.data.length === PAGE_SIZE) { + truncated = true; + break; + } + } + + return { rows, truncated }; +} + +const COLUMNS: GridColDef[] = [ + { + field: 'content', + headerName: 'Reaction', + width: 140, + valueFormatter: (value: string) => `${EMOJI[value] ?? ''} ${value}`.trim(), + }, + { + field: 'user', + headerName: 'User', + flex: 1, + minWidth: 200, + renderCell: (cellParams) => + cellParams.row.userUrl ? ( + + @{cellParams.value} + + ) : ( + cellParams.value + ), + }, +]; + +function targetKey(target: IssueReactionTarget): string { + return `${target.owner}/${target.repo}#${target.number}`; +} + +export default function Reactions() { + const [searchParams, setSearchParams] = useSearchParamsState( + { url: { defaultValue: '' }, all: { defaultValue: '' } }, + { replace: true }, + ); + + const unbounded = searchParams.all === '1'; + const loadAllHref = `/reactions?${new URLSearchParams({ url: searchParams.url, all: '1' })}`; + + const [draft, setDraft] = React.useState(searchParams.url); + React.useEffect(() => { + setDraft(searchParams.url); + }, [searchParams.url]); + + const target = React.useMemo( + () => (searchParams.url ? parseIssueUrl(searchParams.url) : null), + [searchParams.url], + ); + const parseError = Boolean(searchParams.url) && target === null; + + const query = useQuery({ + queryKey: ['reactions', target ? targetKey(target) : null, unbounded], + queryFn: () => fetchReactions(target!, unbounded), + enabled: Boolean(target), + staleTime: 60 * 1000, + retry: false, + }); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setSearchParams({ url: draft.trim(), all: '' }); + }; + + const apiRef = useGridApiRef(); + const rowGroupingModel = React.useMemo(() => ['content'], []); + const initialState = useKeepGroupedColumnsHidden({ apiRef, rowGroupingModel }); + + return ( + + GitHub Reactions + + Paste a public GitHub issue or pull request URL to list every reaction and the users who + left them. Only public repositories are supported. Examples:{' '} + {EXAMPLES.map((example, index) => ( + + {index > 0 ? ', ' : null} + + {example.label} + + + ))} + + + + setDraft(event.target.value)} + /> + + + + {parseError ? ( + + Not a recognized GitHub URL. Expected an issue or pull request link. + + ) : null} + + {query.data?.truncated ? ( + + Showing the first {MAX_PAGES * PAGE_SIZE} reactions. This issue has more —{' '} + + load all reactions + + . This will use more of your hourly GitHub API budget. + + ) : null} + + {query.isError ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml b/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml index c42c6dcfe..e5816857f 100644 --- a/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml +++ b/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml @@ -4,50 +4,17 @@ apiVersion: v1 kind: page spec: title: design_kits_issue_111 - display: shell - queries: - - name: query - query: - kind: rest - url: ' https://api.github.com/repos/mui/mui-design-kits/issues/10/reactions' - headers: - - name: X-GitHub-Api-Version - value: 2022-11-28 - - name: Accept - value: application/vnd.github+json - - name: Authorization - value: - $$jsExpression: | - `Bearer ${parameters.GITHUB_TOKEN}` - method: GET - searchParams: [] - parameters: - - name: GITHUB_TOKEN - value: - $$env: GITHUB_TOKEN + alias: + - _-clGLR + displayName: design_kits_issue_111 content: - component: Text name: text layout: columnSize: 1 props: - value: https://github.com/mui/mui-design-kits/issues/111 upvotes - variant: h4 - - component: DataGrid - name: dataGrid - layout: - columnSize: 1 - props: - rows: - $$jsExpression: | - query.data - .filter((reaction) => reaction.content === "+1") - .map((reaction) => ({ user: reaction.user.html_url })) - columns: - - field: user - type: link - width: 379 - align: left - height: 588 - alias: - - _-clGLR + mode: markdown + value: + "This page has moved to the code-infra-dashboard: + [GitHub Reactions](https://code-infra-dashboard.onrender.com/reactions?url=https%3A%2F%2Fgithub.com%2Fmui%2Fmui-design-kits%2Fissues%2F10)" + display: shell