diff --git a/src/components/FilterButton.tsx b/src/components/FilterButton.tsx index c21e079f..a463701b 100644 --- a/src/components/FilterButton.tsx +++ b/src/components/FilterButton.tsx @@ -26,14 +26,21 @@ const FilterButton: React.FC = ({ onClick={onClick} fullWidth={fullWidth} sx={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 0.75, + lineHeight: 1.2, color: isActive ? activeTextColor : (t) => t.palette.text.secondary, backgroundColor: isActive ? 'surface.light' : 'surface.transparent', borderRadius: '6px', - px: { xs: 1, sm: 1.5 }, - py: { xs: 0.5, sm: 0.75 }, + px: { xs: 1.25, sm: 1.5 }, + py: { xs: 0.5, sm: 0.65 }, + minHeight: 32, minWidth: fullWidth ? 0 : 'auto', textTransform: 'none', - fontSize: { xs: '0.65rem', sm: '0.75rem' }, + fontSize: { xs: '0.7rem', sm: '0.75rem' }, + fontWeight: isActive ? 600 : 500, border: isActive ? `1px solid ${color}` : '1px solid transparent', whiteSpace: 'nowrap', '&:hover': { @@ -41,14 +48,19 @@ const FilterButton: React.FC = ({ }, }} > - {label}{' '} + + {label} + {count !== undefined && ( {count} diff --git a/src/components/repositories/RepositoryIssuesTable.tsx b/src/components/repositories/RepositoryIssuesTable.tsx index af3a4a28..cb09c1dc 100644 --- a/src/components/repositories/RepositoryIssuesTable.tsx +++ b/src/components/repositories/RepositoryIssuesTable.tsx @@ -33,6 +33,7 @@ import { import { STATUS_COLORS, TEXT_OPACITY, scrollbarSx } from '../../theme'; import FilterButton from '../FilterButton'; import TablePagination from '../../components/common/TablePagination'; +import { TableSearchFilter } from './TableSearchFilter'; interface RepositoryIssuesTableProps { repositoryFullName: string; @@ -51,6 +52,24 @@ type RepoIssuesFilter = 'all' | 'open' | 'closed'; const isRepoIssuesFilter = (v: unknown): v is RepoIssuesFilter => v === 'all' || v === 'open' || v === 'closed'; +function issueMatchesSearch( + issue: RepositoryIssue, + searchQuery: string, +): boolean { + const q = searchQuery.trim().toLowerCase(); + if (!q) return true; + const title = getLowerText(issue.title); + if (title.includes(q)) return true; + const author = (issue.authorLogin || issue.author || '').toLowerCase(); + if (author.includes(q)) return true; + const numStr = String(issue.number); + if (numStr.includes(q)) return true; + if (q.startsWith('#')) { + const rest = q.slice(1).trim(); + if (rest && numStr.includes(rest)) return true; + } + return false; +} const ISSUE_PAGE_SIZE = 20; const RepositoryIssuesTable: React.FC = ({ @@ -66,16 +85,24 @@ const RepositoryIssuesTable: React.FC = ({ ); const [sortKey, setSortKey] = useState('number'); const [sortDirection, setSortDirection] = useState('desc'); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + setSearchQuery(''); + }, [repositoryFullName]); + const [page, setPage] = useState(0); const counts = useMemo(() => { if (!issues) return { total: 0, open: 0, closed: 0 }; + const match = (issue: RepositoryIssue) => + issueMatchesSearch(issue, searchQuery); return { - total: issues.length, - open: issues.filter((issue) => !issue.closedAt).length, - closed: issues.filter((issue) => issue.closedAt).length, + total: issues.filter(match).length, + open: issues.filter((issue) => !issue.closedAt).filter(match).length, + closed: issues.filter((issue) => issue.closedAt).filter(match).length, }; - }, [issues]); + }, [issues, searchQuery]); const filteredIssues = useMemo(() => { if (!issues) return []; @@ -84,13 +111,19 @@ const RepositoryIssuesTable: React.FC = ({ return issues; }, [issues, filter]); + const searchFilteredIssues = useMemo(() => { + return filteredIssues.filter((issue) => + issueMatchesSearch(issue, searchQuery), + ); + }, [filteredIssues, searchQuery]); + const sortedIssues = useMemo(() => { const directionFactor = sortDirection === 'asc' ? 1 : -1; const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true, }); - const decorated = filteredIssues.map((issue) => { + const decorated = searchFilteredIssues.map((issue) => { let value: number | string; switch (sortKey) { case 'number': @@ -126,12 +159,12 @@ const RepositoryIssuesTable: React.FC = ({ ); }); return decorated.map((item) => item.issue); - }, [filteredIssues, sortKey, sortDirection]); + }, [searchFilteredIssues, sortKey, sortDirection]); // Reset to the first page whenever the result set changes underneath us. useEffect(() => { setPage(0); - }, [filter, sortKey, sortDirection]); + }, [filter, sortKey, sortDirection, searchQuery]); const totalPages = Math.ceil(sortedIssues.length / ISSUE_PAGE_SIZE); const pagedIssues = useMemo( @@ -303,6 +336,47 @@ const RepositoryIssuesTable: React.FC = ({ }, ]; + const filterButtons = ( + + setFilter('all')} + count={counts.total} + color={STATUS_COLORS.open} + activeTextColor="text.primary" + /> + setFilter('open')} + count={counts.open} + color={STATUS_COLORS.open} + activeTextColor="text.primary" + /> + setFilter('closed')} + count={counts.closed} + color={STATUS_COLORS.merged} + activeTextColor="text.primary" + /> + + + ); + const headerToolbar = ( = ({ > Issues ({sortedIssues.length}) - - setFilter('all')} - count={counts.total} - color={STATUS_COLORS.open} - activeTextColor="text.primary" - /> - setFilter('open')} - count={counts.open} - color={STATUS_COLORS.open} - activeTextColor="text.primary" - /> - setFilter('closed')} - count={counts.closed} - color={STATUS_COLORS.merged} - activeTextColor="text.primary" - /> - + {filterButtons} ); @@ -538,7 +581,11 @@ const RepositoryIssuesTable: React.FC = ({ fontSize: '0.9rem', }} > - No issues found + {searchQuery.trim() && + sortedIssues.length === 0 && + filteredIssues.length > 0 + ? 'No issues match your search.' + : 'No issues found'} } diff --git a/src/components/repositories/TableSearchFilter.tsx b/src/components/repositories/TableSearchFilter.tsx new file mode 100644 index 00000000..d70d98f9 --- /dev/null +++ b/src/components/repositories/TableSearchFilter.tsx @@ -0,0 +1,207 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + Button, + IconButton, + InputAdornment, + Popover, + Stack, + TextField, + Typography, +} from '@mui/material'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import CloseIcon from '@mui/icons-material/Close'; +import SearchIcon from '@mui/icons-material/Search'; + +interface TableSearchFilterProps { + value: string; + onChange: (next: string) => void; + label?: string; + popoverTitle?: string; + placeholder?: string; +} + +export const TableSearchFilter: React.FC = ({ + value, + onChange, + label = 'Search', + popoverTitle = 'Search', + placeholder = 'Search…', +}) => { + const [anchor, setAnchor] = useState(null); + const isOpen = Boolean(anchor); + + const open = useCallback((event: React.MouseEvent) => { + setAnchor(event.currentTarget); + }, []); + + const close = useCallback(() => { + setAnchor(null); + }, []); + + const clear = useCallback(() => { + onChange(''); + close(); + }, [onChange, close]); + + const isFiltering = Boolean(value.trim()); + + return ( + <> + + + + + + {popoverTitle} + + {isFiltering && ( + + + + )} + + onChange(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + '& .MuiOutlinedInput-root': { + fontSize: '0.8rem', + color: 'text.primary', + backgroundColor: 'transparent', + height: 30, + borderRadius: 1.5, + '& fieldset': { borderColor: 'border.light' }, + '&:hover fieldset': { borderColor: 'border.medium' }, + '&.Mui-focused fieldset': { borderColor: 'primary.main' }, + }, + }} + /> + + + + ); +};