diff --git a/src/components/common/TablePagination.tsx b/src/components/common/TablePagination.tsx index b8fd9271..7e2c6f52 100644 --- a/src/components/common/TablePagination.tsx +++ b/src/components/common/TablePagination.tsx @@ -1,88 +1,445 @@ -import React from 'react'; -import { - Box, - IconButton, - Typography, - alpha, - type SxProps, - type Theme, -} from '@mui/material'; -import { - NavigateBefore as PrevIcon, - NavigateNext as NextIcon, -} from '@mui/icons-material'; - -interface TablePaginationProps { - page: number; +/** + * Miner PRs table pagination: URL-backed page/rows (`prPage` / `prRows`), + * paging helpers, `useMinerExplorerPagination`, and the shared page bar + * (Prev / numbers / Next). + */ + +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Box, Typography, alpha, useTheme, useMediaQuery } from '@mui/material'; +import { West as WestIcon, East as EastIcon } from '@mui/icons-material'; + +// --- URL + slice helpers ---------------------------------------------------- + +/** Allowed numeric page sizes (single source of truth for URL + UI). */ +export const MINER_PAGE_SIZES = [5, 10, 20, 50] as const; + +export type MinerExplorerNumericRows = (typeof MINER_PAGE_SIZES)[number]; +export type MinerExplorerRowsOption = MinerExplorerNumericRows | 'all'; + +export const MINER_EXPLORER_ROWS_OPTIONS: readonly MinerExplorerRowsOption[] = [ + ...MINER_PAGE_SIZES, + 'all', +]; + +const PAGE_SIZE_WHITELIST = new Set(MINER_PAGE_SIZES); + +export const DEFAULT_MINER_EXPLORER_ROWS: MinerExplorerRowsOption = 20; + +export const MINER_EXPLORER_PAGE_PARAM = 'prPage'; +export const MINER_EXPLORER_ROWS_PARAM = 'prRows'; + +export const MINER_EXPLORER_ROWS_SELECT_SX = { + color: 'text.primary', + backgroundColor: 'background.default', + fontSize: '0.8rem', + height: '36px', + borderRadius: 2, + minWidth: '80px', + '& fieldset': { borderColor: 'border.light' }, + '&:hover fieldset': { + borderColor: 'border.medium', + }, + '&.Mui-focused fieldset': { borderColor: 'primary.main' }, + '& .MuiSelect-select': { py: 0.75 }, +} as const; + +export function parseMinerExplorerRowsParam( + raw: string | null, +): MinerExplorerRowsOption { + if (raw === 'all') return 'all'; + if (raw == null || raw === '') return DEFAULT_MINER_EXPLORER_ROWS; + const n = parseInt(raw, 10); + if (Number.isNaN(n)) return DEFAULT_MINER_EXPLORER_ROWS; + if (PAGE_SIZE_WHITELIST.has(n)) return n as MinerExplorerNumericRows; + return DEFAULT_MINER_EXPLORER_ROWS; +} + +export function parseMinerExplorerPageParam(raw: string | null): number { + if (!raw) return 0; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +} + +export function getMinerExplorerPaging( + items: readonly T[], + page: number, + rows: MinerExplorerRowsOption, +): { totalPages: number; - onPageChange: (newPage: number) => void; + safePage: number; + slice: T[]; + showPageNav: boolean; +} { + const isAll = rows === 'all'; + const pageSize = isAll ? Math.max(items.length, 1) : rows; + const totalPages = isAll + ? 1 + : Math.max(1, Math.ceil(items.length / pageSize)); + const safePage = Math.min(page, totalPages - 1); + const start = safePage * pageSize; + const slice = isAll ? [...items] : items.slice(start, start + pageSize); + const showPageNav = !isAll && totalPages > 1; + return { totalPages, safePage, slice, showPageNav }; } -const navButtonSx: SxProps = { - p: 0.25, - color: (t) => alpha(t.palette.text.primary, 0.6), - '&:hover': { color: 'text.primary' }, - '&.Mui-focusVisible': { - backgroundColor: (t) => alpha(t.palette.primary.main, 0.2), - outline: '2px solid', - outlineColor: 'primary.main', - outlineOffset: '-2px', - color: 'text.primary', - }, - '&.Mui-disabled': { opacity: 0.3 }, +// --- Hook ------------------------------------------------------------------- + +type UseMinerExplorerPaginationOptions = { + resetKey?: string; + totalItemCount: number; }; -const navIconSx = { fontSize: '1.2rem' }; +export function useMinerExplorerPagination({ + resetKey, + totalItemCount, +}: UseMinerExplorerPaginationOptions) { + const [searchParams, setSearchParams] = useSearchParams(); + + const rowsPerPage = useMemo( + () => + parseMinerExplorerRowsParam(searchParams.get(MINER_EXPLORER_ROWS_PARAM)), + [searchParams], + ); + + const page = useMemo( + () => + parseMinerExplorerPageParam(searchParams.get(MINER_EXPLORER_PAGE_PARAM)), + [searchParams], + ); + + const setPage = useCallback( + (updater: number | ((prev: number) => number)) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + const current = parseMinerExplorerPageParam( + next.get(MINER_EXPLORER_PAGE_PARAM), + ); + const resolved = + typeof updater === 'function' ? updater(current) : updater; + if (resolved <= 0) next.delete(MINER_EXPLORER_PAGE_PARAM); + else next.set(MINER_EXPLORER_PAGE_PARAM, String(resolved)); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setPageRef = useRef(setPage); + setPageRef.current = setPage; + + const setRowsPerPage = useCallback( + (nextRows: MinerExplorerRowsOption) => { + setSearchParams( + (prev) => { + const p = new URLSearchParams(prev); + p.delete(MINER_EXPLORER_PAGE_PARAM); + if (nextRows === DEFAULT_MINER_EXPLORER_ROWS) + p.delete(MINER_EXPLORER_ROWS_PARAM); + else p.set(MINER_EXPLORER_ROWS_PARAM, String(nextRows)); + return p; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const prevResetKey = useRef(resetKey); + useEffect(() => { + if (resetKey === undefined) return; + if (prevResetKey.current === resetKey) return; + prevResetKey.current = resetKey; + setPageRef.current(0); + }, [resetKey]); + + useEffect(() => { + const isAll = rowsPerPage === 'all'; + const size = isAll ? Math.max(totalItemCount, 1) : rowsPerPage; + const totalPages = Math.max(1, Math.ceil(totalItemCount / size)); + const last = totalPages - 1; + if (page > last) setPageRef.current(last); + }, [totalItemCount, rowsPerPage, page]); -const TablePagination: React.FC = ({ + return { + page, + setPage, + rowsPerPage, + setRowsPerPage, + }; +} + +// --- Page bar UI ------------------------------------------------------------ + +/** Pages shown around current when using ellipsis (1 … 4 5 6 … 20). */ +const PAGER_WINDOW_DELTA = 2; +/** Below this many pages, show every page number (no ellipsis). */ +const PAGER_FULL_LIST_MAX = PAGER_WINDOW_DELTA * 2 + 5; + +const PAGER_BUTTON_RESET = { + border: 'none', + background: 'none', + fontFamily: 'inherit', + p: 0, +} as const; + +function buildPaginationItems( + currentPageOneBased: number, + totalPages: number, + delta = PAGER_WINDOW_DELTA, +): Array { + if (totalPages <= 1) return []; + if (totalPages <= PAGER_FULL_LIST_MAX) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + const pages = new Set(); + pages.add(1); + pages.add(totalPages); + for ( + let i = Math.max(1, currentPageOneBased - delta); + i <= Math.min(totalPages, currentPageOneBased + delta); + i++ + ) { + pages.add(i); + } + const sorted = [...pages].sort((a, b) => a - b); + const result: Array = []; + let prev: number | undefined; + for (const p of sorted) { + if (prev !== undefined && p - prev > 1) { + result.push('ellipsis'); + } + result.push(p); + prev = p; + } + return result; +} + +export interface TablePaginationProps { + page: number; + totalPages: number; + onPageChange: (newPage: number) => void; +} + +const TablePagination = memo(function TablePagination({ page, totalPages, onPageChange, -}) => { +}: TablePaginationProps) { + const theme = useTheme(); + const isMobilePager = useMediaQuery(theme.breakpoints.down('sm')); + const primary = theme.palette.primary.main; + const activeFg = + theme.palette.primary.contrastText ?? theme.palette.common.white; + const items = useMemo( + () => buildPaginationItems(page + 1, totalPages), + [page, totalPages], + ); + + const navSx = (enabled: boolean) => ({ + display: 'inline-flex', + alignItems: 'center', + gap: 0.25, + fontSize: '0.8125rem', + fontWeight: 500, + color: enabled ? primary : alpha(theme.palette.text.primary, 0.22), + cursor: enabled ? 'pointer' : 'default', + userSelect: 'none' as const, + '&:hover': enabled ? { opacity: 0.88 } : {}, + }); + + const outerBarSx = { + py: 1.5, + px: 1, + borderTop: '1px solid', + borderColor: 'border.subtle', + } as const; + if (totalPages <= 1) { return null; } + if (isMobilePager) { + const canPrev = page > 0; + const canNext = page < totalPages - 1; + return ( + + + canPrev && onPageChange(page - 1)} + sx={{ + ...navSx(canPrev), + ...PAGER_BUTTON_RESET, + flexShrink: 0, + }} + > + + + Prev + + + + + {page + 1} / {totalPages} + + + canNext && onPageChange(page + 1)} + sx={{ + ...navSx(canNext), + ...PAGER_BUTTON_RESET, + flexShrink: 0, + }} + > + + Next + + + + + + ); + } + return ( - onPageChange(page - 1)} - disabled={page === 0} + page > 0 && onPageChange(page - 1)} + sx={{ + ...navSx(page > 0), + ...PAGER_BUTTON_RESET, + }} > - - - + Prev + + + alpha(t.palette.text.primary, 0.5), + display: 'flex', + alignItems: 'center', + gap: { xs: 0.35, sm: 0.5 }, + mx: { xs: 0.5, sm: 1 }, }} > - {page + 1} / {totalPages} - - onPageChange(page + 1)} - disabled={page >= totalPages - 1} + {items.map((item, idx) => + item === 'ellipsis' ? ( + + ... + + ) : ( + onPageChange(item - 1)} + sx={{ + ...PAGER_BUTTON_RESET, + minWidth: 36, + height: 32, + px: 1, + borderRadius: 1, + backgroundColor: item === page + 1 ? primary : 'transparent', + color: item === page + 1 ? activeFg : 'text.primary', + fontSize: '0.8125rem', + fontWeight: item === page + 1 ? 600 : 400, + cursor: 'pointer', + lineHeight: 1, + '&:hover': { + backgroundColor: + item === page + 1 + ? primary + : alpha(theme.palette.text.primary, 0.06), + }, + }} + > + {item} + + ), + )} + + + page < totalPages - 1 && onPageChange(page + 1)} + sx={{ + ...navSx(page < totalPages - 1), + ...PAGER_BUTTON_RESET, + }} > - - + Next + + ); -}; +}); export default TablePagination; diff --git a/src/components/miners/MinerPRsTable.tsx b/src/components/miners/MinerPRsTable.tsx index 37ca8d02..4671662e 100644 --- a/src/components/miners/MinerPRsTable.tsx +++ b/src/components/miners/MinerPRsTable.tsx @@ -31,7 +31,6 @@ import { getRepositoryOwnerAvatarSrc, getPrStatusCounts, isOutsideScoringWindow, - paginateItems, type PrStatusFilter, } from '../../utils'; import { @@ -47,7 +46,12 @@ import { serializePRKey, useWatchlist, } from '../../hooks/useWatchlist'; -import TablePagination from '../common/TablePagination'; +import MinerTableRowsSelect from './MinerTableRowsSelect'; +import TablePagination, { + getMinerExplorerPaging, + MINER_EXPLORER_PAGE_PARAM, + useMinerExplorerPagination, +} from '../common/TablePagination'; import { formatDate } from '../../utils/format'; import { tooltipSlotProps } from '../../theme'; import MinerPrScoreDetail from './MinerPrScoreDetail'; @@ -61,8 +65,6 @@ type PrSortField = | 'watch'; type SortDir = 'asc' | 'desc'; -const PAGE_SIZE = 20; - const PR_STATUS_FILTERS: readonly PrStatusFilter[] = [ 'all', 'open', @@ -140,41 +142,6 @@ const MinerPRsTable: React.FC = ({ githubId }) => { setExpandedKeys(new Set()); }, [githubId]); - const page = parseInt(searchParams.get('prPage') || '0', 10); - const setPage = useCallback( - (updater: number | ((prev: number) => number)) => { - const next = typeof updater === 'function' ? updater(page) : updater; - setSearchParams( - (prev) => { - const p = new URLSearchParams(prev); - if (next === 0) p.delete('prPage'); - else p.set('prPage', String(next)); - return p; - }, - { replace: true }, - ); - }, - [page, setSearchParams], - ); - - // Ref lets the callback read the latest searchQuery without closing over - // it (which would re-fire the wrapper's debounce effect on every commit). - const searchQueryRef = useRef(searchQuery); - useEffect(() => { - searchQueryRef.current = searchQuery; - }); - - // Skip when the wrapper fires with the already-committed value (mount + the - // [githubId] reset above) so a deep-linked `?prPage=N` survives. - const handleDebouncedSearch = useCallback( - (next: string) => { - if (next === searchQueryRef.current) return; - setSearchQuery(next); - setPage(0); - }, - [setPage], - ); - const setStatusFilter = useCallback( (next: PrStatusFilter) => { setSearchParams( @@ -182,7 +149,7 @@ const MinerPRsTable: React.FC = ({ githubId }) => { const p = new URLSearchParams(prev); if (next === 'all') p.delete('prStatus'); else p.set('prStatus', next); - p.delete('prPage'); + p.delete(MINER_EXPLORER_PAGE_PARAM); return p; }, { replace: true }, @@ -191,19 +158,6 @@ const MinerPRsTable: React.FC = ({ githubId }) => { [setSearchParams], ); - const handleSort = useCallback( - (field: PrSortField) => { - if (sortField === field) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortField(field); - setSortDir(DEFAULT_SORT_DIR[field]); - } - setPage(0); - }, - [sortField, setPage], - ); - const filteredPRs = useMemo( () => filterPrs(prs ?? [], { @@ -248,12 +202,45 @@ const MinerPRsTable: React.FC = ({ githubId }) => { return sorted; }, [filteredPRs, sortField, sortDir, isWatched]); - const pagedPRs = useMemo( - () => paginateItems(sortedPRs, page, PAGE_SIZE), - [sortedPRs, page], + const { page, setPage, rowsPerPage, setRowsPerPage } = + useMinerExplorerPagination({ + resetKey: githubId, + totalItemCount: sortedPRs.length, + }); + + const paging = useMemo( + () => getMinerExplorerPaging(sortedPRs, page, rowsPerPage), + [sortedPRs, page, rowsPerPage], + ); + + const { slice: pagedPRs, totalPages, safePage, showPageNav } = paging; + + const searchQueryRef = useRef(searchQuery); + useEffect(() => { + searchQueryRef.current = searchQuery; + }); + + const handleDebouncedSearch = useCallback( + (next: string) => { + if (next === searchQueryRef.current) return; + setSearchQuery(next); + setPage(0); + }, + [setPage], ); - const totalPages = Math.ceil(sortedPRs.length / PAGE_SIZE); + const handleSort = useCallback( + (field: PrSortField) => { + if (sortField === field) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDir(DEFAULT_SORT_DIR[field]); + } + setPage(0); + }, + [sortField, setPage], + ); // Count over the search + author scope (excluding the active status filter) // so each button reflects what the user would see if they clicked it. @@ -616,52 +603,70 @@ const MinerPRsTable: React.FC = ({ githubId }) => { - - {({ draftValue, setDraftValue }) => ( - setDraftValue(e.target.value)} - InputProps={{ - startAdornment: ( - - alpha(t.palette.text.primary, 0.3), - fontSize: '1rem', - }} + + + {({ draftValue, setDraftValue }) => ( + setDraftValue(e.target.value)} + InputProps={{ + startAdornment: ( + + alpha(t.palette.text.primary, 0.3), + fontSize: '1rem', + }} + /> + + ), + endAdornment: ( + setDraftValue('')} /> - - ), - endAdornment: ( - setDraftValue('')} - /> - ), - }} - sx={{ - mt: 2, - width: { xs: '100%', sm: 'auto' }, - maxWidth: { xs: '100%', sm: 400 }, - minWidth: { xs: 0, sm: 350 }, - '& .MuiOutlinedInput-root': { - fontSize: '0.8rem', - color: 'text.primary', - backgroundColor: 'surface.subtle', - borderRadius: 2, - '& fieldset': { borderColor: 'border.light' }, - '&:hover fieldset': { borderColor: 'border.medium' }, - '&.Mui-focused fieldset': { borderColor: 'primary.main' }, - }, - }} - /> - )} - + ), + }} + sx={{ + flex: 1, + minWidth: 0, + width: 'auto', + maxWidth: { xs: '100%', sm: 480 }, + '& .MuiOutlinedInput-root': { + fontSize: '0.8rem', + color: 'text.primary', + backgroundColor: 'surface.subtle', + borderRadius: 2, + '& fieldset': { borderColor: 'border.light' }, + '&:hover fieldset': { borderColor: 'border.medium' }, + '&.Mui-focused fieldset': { borderColor: 'primary.main' }, + }, + }} + /> + )} + + ); @@ -724,11 +729,13 @@ const MinerPRsTable: React.FC = ({ githubId }) => { onChange: handleSort, }} pagination={ - + showPageNav ? ( + + ) : null } /> diff --git a/src/components/miners/MinerTableRowsSelect.tsx b/src/components/miners/MinerTableRowsSelect.tsx new file mode 100644 index 00000000..0a819e7b --- /dev/null +++ b/src/components/miners/MinerTableRowsSelect.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Box, FormControl, MenuItem, Select, Typography } from '@mui/material'; +import { + MINER_EXPLORER_ROWS_OPTIONS, + MINER_EXPLORER_ROWS_SELECT_SX, + type MinerExplorerRowsOption, +} from '../common/TablePagination'; + +export interface MinerTableRowsSelectProps { + value: MinerExplorerRowsOption; + onChange: (next: MinerExplorerRowsOption) => void; + id?: string; +} + +/** Rows-per-page control for the Miner PRs table header toolbar. */ +const MinerTableRowsSelect: React.FC = ({ + value, + onChange, + id = 'miner-table-rows', +}) => ( + + + + Rows: + + + + +); + +export default MinerTableRowsSelect;