diff --git a/package.json b/package.json index 60f55fb..9ffa59b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daily-helper", - "version": "2.3.0", + "version": "2.4.0", "private": true, "scripts": { "start": "vite", diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx index a72e403..7e1c5c1 100644 --- a/src/components/AppBar.tsx +++ b/src/components/AppBar.tsx @@ -15,6 +15,7 @@ import IconButton from '@mui/material/IconButton' import LinearProgress from '@mui/material/LinearProgress' import MenuIcon from '@mui/icons-material/Menu' import ReloadIcon from '@mui/icons-material/Replay' +import SearchBar from './SearchBar' import Settings from './settings/Settings' import SettingsIcon from '@mui/icons-material/Settings' import Tab from '@mui/material/Tab' @@ -35,6 +36,8 @@ type AppBarElementProps = { drawbarName: string isDrawbarOpen: boolean setIsDrawbarOpen: (newState: boolean) => void + onSearch?: (query: string) => void + showSearch?: boolean } export default function AppBarElement({ handleReload: initialHandleReload, @@ -45,6 +48,8 @@ export default function AppBarElement({ drawbarName, isDrawbarOpen, setIsDrawbarOpen, + onSearch, + showSearch = true, }: AppBarElementProps) { const relativeTime = useRelativeTime(lastUpdated) const [teamTabValue, setTeamTabValue] = useState(0) @@ -118,6 +123,7 @@ export default function AppBarElement({ + {onSearch && showSearch && } {showDrawbar && ( diff --git a/src/components/PullRequestFilterBar.tsx b/src/components/PullRequestFilterBar.tsx index 4a57633..a883d88 100644 --- a/src/components/PullRequestFilterBar.tsx +++ b/src/components/PullRequestFilterBar.tsx @@ -24,28 +24,34 @@ function FilterButton({ onClick, }: FilterButtonProps) { return ( - - - - {label} - - + + + + + {label} + + + ) } diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..0d041fa --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react' + +import CloseIcon from '@mui/icons-material/Close' +import IconButton from '@mui/material/IconButton' +import InputAdornment from '@mui/material/InputAdornment' +import SearchIcon from '@mui/icons-material/Search' +import TextField from '@mui/material/TextField' +import Tooltip from '@mui/material/Tooltip' +import { useDebounce } from '../hooks/useDebounce' + +const DEBOUNCE_MS = 120 + +type SearchBarProps = { + onSearch: (query: string) => void +} + +export default function SearchBar({ onSearch }: SearchBarProps) { + const [expanded, setExpanded] = useState(false) + const [value, setValue] = useState('') + const debouncedValue = useDebounce(value, DEBOUNCE_MS) + + useEffect(() => { + onSearch(debouncedValue) + }, [debouncedValue, onSearch]) + + const collapseIfEmpty = () => { + if (!value) setExpanded(false) + } + + const clear = () => { + setValue('') + setExpanded(false) + } + + if (!expanded) { + return ( + + setExpanded(true)} + > + + + + ) + } + + return ( + setValue(e.target.value)} + onBlur={collapseIfEmpty} + onKeyDown={e => { + if (e.key === 'Escape') clear() + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + + e.preventDefault()} + onClick={clear} + > + + + + ), + }} + sx={{ + width: { xs: 150, sm: 240 }, + '& .MuiOutlinedInput-root': { + color: 'inherit', + bgcolor: 'rgba(255, 255, 255, 0.12)', + '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.3)' }, + '&:hover fieldset': { borderColor: 'rgba(255, 255, 255, 0.5)' }, + '&.Mui-focused fieldset': { + borderColor: 'rgba(255, 255, 255, 0.7)', + }, + }, + '& .MuiInputBase-input::placeholder': { + color: 'inherit', + opacity: 0.7, + }, + }} + /> + ) +} diff --git a/src/helpers/prFilters.ts b/src/helpers/prFilters.ts index 600507b..2a83f5a 100644 --- a/src/helpers/prFilters.ts +++ b/src/helpers/prFilters.ts @@ -17,6 +17,24 @@ function isTitleWhitelisted(title: string, filters: Settings_Filters): boolean { ) } +export function matchesSearch(pr: PullRequest, rawQuery: string): boolean { + const query = rawQuery.trim().toLowerCase() + if (!query) return true + + const userMatches = (user: User) => + getDisplayName(user).toLowerCase().includes(query) || + user.login.toLowerCase().includes(query) + + return ( + pr.title.toLowerCase().includes(query) || + `#${pr.number}`.includes(query) || + pr.repositoryName.toLowerCase().includes(query) || + userMatches(pr.author) || + pr.assignees.some(userMatches) || + pr.labels.some(label => label.name.toLowerCase().includes(query)) + ) +} + export function applyReviewRequiredFilter( prs: PullRequest[], viewerLogin: string, diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..8523ae1 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react' + +export function useDebounce(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs) + return () => clearTimeout(id) + }, [value, delayMs]) + + return debounced +} diff --git a/src/views/DailyHelper/DailyHelper.tsx b/src/views/DailyHelper/DailyHelper.tsx index 963f38b..5d5f79b 100644 --- a/src/views/DailyHelper/DailyHelper.tsx +++ b/src/views/DailyHelper/DailyHelper.tsx @@ -21,7 +21,10 @@ import Stack from '@mui/material/Stack' import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' import VisibleIcon from '@mui/icons-material/Visibility' -import { applyReviewRequiredFilter } from '../../helpers/prFilters' +import { + applyReviewRequiredFilter, + matchesSearch, +} from '../../helpers/prFilters' import { getDisplayName } from '../../helpers/getDisplayName' import { getStateRank } from '../../helpers/getStateRank' import { dataFetcher } from '../../helpers/dataFetcher' @@ -68,6 +71,7 @@ export default function DailyHelper() { const isMyPrsFilterActive = activeFilter === 'myPrs' const isMyWorkFilterActive = activeFilter === 'myWork' const [viewerLogin, setViewerLogin] = useState(null) + const [searchQuery, setSearchQuery] = useState('') const [activeView, setActiveView] = useState<'list' | 'kanban' | 'notes'>( settingsHandler.loadView(), ) @@ -191,7 +195,8 @@ export default function DailyHelper() { (!reviewRequiredFilteredIds || reviewRequiredFilteredIds.has(pr.id)) && (!isMyPrsFilterActive || pr.author.login === viewerLogin) && - (!isMyWorkFilterActive || isMyWork(pr)), + (!isMyWorkFilterActive || isMyWork(pr)) && + matchesSearch(pr, searchQuery), ), [ sortedPullRequests, @@ -202,6 +207,7 @@ export default function DailyHelper() { viewerLogin, hiddenLabels, isPullRequestsWithoutLabelsHidden, + searchQuery, ], ) @@ -275,6 +281,8 @@ export default function DailyHelper() { drawbarName="Search filters" isDrawbarOpen={isDrawbarOpen} setIsDrawbarOpen={setIsDrawbarOpen} + onSearch={setSearchQuery} + showSearch={activeView !== 'notes'} />