Consolidate search options into a single Filter modal (#497)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

* Consolidate search options into a single Filter modal

Reworks the file-search UI on the Dashboard, Videos, Archives, and Docs
pages. The crowded row of controls (sort, tags, limit, file-type filter,
date selector) is replaced with a single "Filter" button that opens one
comprehensive modal.

- Add SearchFilterModal + SearchFilterButton (violet + count badge when
  active). Sections (Sort, File Type, Tags, Results Per Page, Published
  Date) are shown per-page via props; layout uses a Semantic Grid.
- Selections are drafted locally and applied in a single updateQuery only
  on close/Done, so the search isn't re-run on every field change.
- Results Per Page and Sort use buttons (fewer clicks, no clipped dropdown).
- Extract shared SearchControlBar (view toggle + input + Filter) for the
  three results pages; move the global-search view toggle onto the tab row.
- Rework the Dashboard search line into one adaptive flex row; clear button
  only shows when there's an active search.
- Remove now-dead FilesView limit/tag returns and SearchLimitDropdown.

* Address review: reset limit on Clear All; use replace over replaceAll

- handleClearAll now resets Results Per Page to the default page size
  (DEFAULT_SEARCH_LIMIT = 24) so "Clear All" actually clears every setting.
- Replace start-anchored replaceAll(/^-/g) with replace(/^-/) for the sort key.
- Add a test covering the Clear All limit reset.

* Remove now-unused Media import from Files.js

The FilesSearchView refactor dropped the last <Media> usage in this file,
leaving an unused import (a build warning). Remove it.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-06-23 19:49:15 -06:00 • committed by Roland
parent 36b29afef7
commit 264cc8cd58
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
9 changed files with 549 additions and 243 deletions

View file

@ -33,8 +33,7 @@ import {SearchView, useSearch, useSearchSuggestions} from "./components/Search";
import {OutdatedZimsMessage} from "./components/Zim";
import {useSearchFilter, useSearchRecentFiles, useWROLMode} from "./hooks/customHooks";
import {ExtensionInstallSuggestion} from "./components/admin/ExtensionInstallSuggestion";
import {FileCards, FileSearchFilterButton} from "./components/Files";
import {DateSelectorButton} from "./components/DatesSelector";
import {fileMimetypeFilterOptions, FileCards, SearchFilterButton} from "./components/Files";
import {useCalculators} from "./components/Calculators";
import {classifyAndEvaluate} from "./components/calculators/mathConfig";
@ -394,7 +393,7 @@ export function DashboardPage() {
loading,
setSearchStr: setSuggestionSearchStr,
setSearchTags,
months, dateRange, setDates, clearDate,
clearDate,
} = useSearchSuggestions(searchStr, activeTags, anyTag);
React.useEffect(() => {
@ -444,7 +443,7 @@ export function DashboardPage() {
}
const getSearchResultsInput = (props) => {
return <SearchResultsInput clearable
return <SearchResultsInput clearable={!isEmpty}
searchStr={localSearchStr}
onChange={setLocalSearchStr}
onSubmit={setSearchStr}
@ -461,58 +460,23 @@ export function DashboardPage() {
/>;
};
// The search input grows to fill the row; the Filter button sits immediately after it. Flexbox adapts
// to the client width, so only the control size changes between mobile and larger screens.
const searchRow = (big) => {
const inputProps = {style: {flexGrow: 1, minWidth: 0, marginBottom: 0}};
if (big) {
inputProps.size = 'big';
}
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em', marginBottom: '2em'}}>
{getSearchResultsInput(inputProps)}
<SearchFilterButton fileFilterOptions={fileMimetypeFilterOptions} showDates={true}
size={big ? 'big' : undefined}/>
</div>;
};
return <PageContainer>
<Media at='mobile'>
<Grid>
<Grid.Row columns={2}>
<Grid.Column width={12}>
{getSearchResultsInput()}
</Grid.Column>
<Grid.Column width={1} textAlign='right' style={{padding: 0}}>
<DateSelectorButton defaultMonthsSelected={months} defaultDateRange={dateRange}
onClear={clearDate} onDatesChange={setDates}
/>
</Grid.Column>
<Grid.Column width={1} textAlign='right'>
<FileSearchFilterButton/>
</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<Media between={['tablet', 'computer']}>
<Grid>
<Grid.Row columns={2}>
<Grid.Column textAlign='right' width={2}>
<DateSelectorButton defaultMonthsSelected={months} defaultDateRange={dateRange}
onClear={clearDate} onDatesChange={setDates}
buttonProps={{size: 'big'}}/>
</Grid.Column>
<Grid.Column textAlign='right' width={2}>
<FileSearchFilterButton size='big'/>
</Grid.Column>
<Grid.Column mobile={12}>
{getSearchResultsInput({size: 'big'})}
</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<Media greaterThanOrEqual='computer'>
<Grid>
<Grid.Row columns={2}>
<Grid.Column textAlign='right' width={1}>
<DateSelectorButton defaultMonthsSelected={months} defaultDateRange={dateRange}
onClear={clearDate} onDatesChange={setDates}
buttonProps={{size: 'big'}}/>
</Grid.Column>
<Grid.Column textAlign='right' width={1}>
<FileSearchFilterButton size='big'/>
</Grid.Column>
<Grid.Column mobile={14}>
{getSearchResultsInput({size: 'big'})}
</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<Media at='mobile'>{searchRow(false)}</Media>
<Media greaterThanOrEqual='tablet'>{searchRow(true)}</Media>
{!searchStr && <FlagsMessages/>}
{!searchStr && <ExtensionInstallSuggestion/>}
{body}

View file

@ -38,7 +38,6 @@ import {
PreviewPath,
resolveDataPath,
SearchInput,
SortButton,
TabLinks,
textEllipsis,
useTitle
@ -78,10 +77,10 @@ import {
useSearchDomain,
useSearchOrder
} from "../hooks/customHooks";
import {FileCards, FileRowTagIcon, FilesView} from "./Files";
import {FileCards, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import _ from "lodash";
import {Media, ThemeContext} from "../contexts/contexts";
import {ThemeContext} from "../contexts/contexts";
import {Button, Card, darkTheme, Header, Loader, Placeholder, Popup, Segment, Statistic, Tab} from "./Theme";
import {BulkTagModal} from "./BulkTagModal";
import {taggedImageLabel, TagsSelector} from "../Tags";
@ -1216,7 +1215,7 @@ function ArchivesPage() {
/>
</div>;
const {body, paginator, viewButton, limitDropdown, tagQuerySelector} = FilesView(
const {body, paginator, viewButton} = FilesView(
{
files: archives,
activePage: activePage,
@ -1230,16 +1229,6 @@ function ArchivesPage() {
);
const [localSearchStr, setLocalSearchStr] = React.useState(searchStr || '');
const searchInput = <SearchInput
onChange={setLocalSearchStr}
onClear={() => setLocalSearchStr(null)}
searchStr={localSearchStr}
onSubmit={setSearchStr}
placeholder='Search Archives...'
inputRef={searchInputRef}
/>;
// Domain header with edit link when filtering by domain
let header;
if (domainObj && domainObj.domain) {
@ -1263,30 +1252,14 @@ function ArchivesPage() {
return <>
{header}
<Media at='mobile'>
<Grid>
<Grid.Row>
<Grid.Column width={2}>{viewButton}</Grid.Column>
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
<Grid.Column width={8}><SortButton sorts={archiveOrders}/></Grid.Column>
</Grid.Row>
<Grid.Row width={16}>
<Grid.Column>{searchInput}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<Media greaterThanOrEqual='tablet'>
<Grid>
<Grid.Row>
<Grid.Column width={1}>{viewButton}</Grid.Column>
<Grid.Column width={2}>{limitDropdown}</Grid.Column>
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
<Grid.Column width={3}><SortButton sorts={archiveOrders}/></Grid.Column>
<Grid.Column width={9}>{searchInput}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<SearchControlBar
searchStr={searchStr}
setSearchStr={setSearchStr}
placeholder='Search Archives...'
inputRef={searchInputRef}
viewButton={viewButton}
sorts={archiveOrders}
/>
{body}
{paginator}
<TaggedDeleteConfirmModal

View file

@ -709,7 +709,7 @@ export function textEllipsis(str, maxLength = 100, {side = "end", ellipsis = "..
return str;
}
export function TabLinks({links}) {
export function TabLinks({links, right}) {
return <Menu tabular>
{links.map((link) => <NavLink
to={link.to}
@ -723,6 +723,10 @@ export function TabLinks({links}) {
>
{link.text}
</NavLink>)}
{right &&
<Menu.Menu position='right'>
<Menu.Item style={{padding: '0.5em'}}>{right}</Menu.Item>
</Menu.Menu>}
</Menu>
}

View file

@ -9,7 +9,7 @@ export const dateRangeIsEmpty = (dateRange) => {
return dateRange[0] === null && dateRange[1] === null;
}
function MonthsForm({monthsSelected, setMonthsSelected}) {
export function MonthsForm({monthsSelected, setMonthsSelected}) {
monthsSelected = monthsSelected.map(i => parseInt(i));
const handleWinter = (e) => {
@ -83,7 +83,7 @@ function MonthsForm({monthsSelected, setMonthsSelected}) {
</Form>
}
function DateRangeForm({dateRange, setDateRange}) {
export function DateRangeForm({dateRange, setDateRange}) {
const currentYear = (new Date()).getFullYear();
const [error, setError] = React.useState('');

View file

@ -14,7 +14,6 @@ import {
PageContainer,
PreviewPath,
SearchInput,
SortButton,
TabLinks,
toLocaleString,
useTitle
@ -22,7 +21,7 @@ import {
import {Button, darkTheme, Header, Icon, Segment, Statistic, Tab} from "./Theme";
import {BulkTagModal} from "./BulkTagModal";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {DocSearchFilterButton, FilesView} from "./Files";
import {docMimetypeFilterOptions, FilesView, SearchControlBar} from "./Files";
import {useAuthors, useDoc, useOneQuery, useSearchDocs, useSubjects} from "../hooks/customHooks";
import {TagsSelector} from "../Tags";
import {toast} from "react-semantic-toasts-2";
@ -116,7 +115,7 @@ function DocsPage() {
docOrders = [{value: 'rank', text: 'Rank'}, ...docOrders];
}
const {body, paginator, viewButton, limitDropdown, tagQuerySelector} = FilesView(
const {body, paginator, viewButton} = FilesView(
{
files: docs,
activePage: activePage,
@ -128,45 +127,18 @@ function DocsPage() {
},
);
const [localSearchStr, setLocalSearchStr] = React.useState(searchStr || '');
const searchInput = <SearchInput
onChange={setLocalSearchStr}
onClear={() => setLocalSearchStr(null)}
searchStr={localSearchStr}
onSubmit={setSearchStr}
placeholder='Search Documents...'
inputRef={searchInputRef}
/>;
return <>
{author && <Header as='h1'>Author: {author}</Header>}
{subject && <Header as='h1'>Subject: {subject}</Header>}
<Media at='mobile'>
<Grid>
<Grid.Row>
<Grid.Column width={2}>{viewButton}</Grid.Column>
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
<Grid.Column width={2}><DocSearchFilterButton/></Grid.Column>
<Grid.Column width={6}><SortButton sorts={docOrders}/></Grid.Column>
</Grid.Row>
<Grid.Row width={16}>
<Grid.Column>{searchInput}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<Media greaterThanOrEqual='tablet'>
<Grid>
<Grid.Row>
<Grid.Column width={1}>{viewButton}</Grid.Column>
<Grid.Column width={2}>{limitDropdown}</Grid.Column>
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
<Grid.Column width={1}><DocSearchFilterButton/></Grid.Column>
<Grid.Column width={3}><SortButton sorts={docOrders}/></Grid.Column>
<Grid.Column width={8}>{searchInput}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<SearchControlBar
searchStr={searchStr}
setSearchStr={setSearchStr}
placeholder='Search Documents...'
inputRef={searchInputRef}
viewButton={viewButton}
sorts={docOrders}
fileFilterOptions={docMimetypeFilterOptions}
/>
{body}
{paginator}
<TaggedDeleteConfirmModal

View file

@ -6,9 +6,9 @@ import {
CardMeta,
Checkbox,
Container,
Dropdown,
Form,
Image,
Label,
PlaceholderLine,
Step,
TableCell,
@ -29,12 +29,14 @@ import {
PageContainer,
Paginator,
PreviewLink,
SearchInput,
TagIcon,
textEllipsis,
useTitle
} from "./Common";
import {
usePages,
useSearchDate,
useSearchFiles,
useSearchFilter,
useSearchOrder,
@ -47,10 +49,12 @@ import {Link, Route, Routes, useSearchParams} from "react-router";
import {CardPlaceholder} from "./Placeholder";
import {ArchiveCard, ArchiveRowCells} from "./Archive";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Media, ThemeContext} from "../contexts/contexts";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import {
Button,
Card,
Divider,
Header,
Icon,
Modal,
Placeholder,
@ -58,6 +62,7 @@ import {
Progress,
Segment
} from "./Theme";
import {DateRangeForm, dateRangeIsEmpty, MonthsForm} from "./DatesSelector";
import {SelectableTable} from "./Tables";
import {VideoCard, VideoRowCells} from "./Videos";
import {FileBrowser} from "./FileBrowser";
@ -349,20 +354,6 @@ export function SearchViewButton({headlines}) {
return <Button icon='browser' onClick={() => setView('list')}/>;
}
export function SearchLimitDropdown({limits = []}) {
const {limit, setLimit} = usePages();
const limitOptions = limits.map(i => {
return {key: i, value: i, text: i.toString()}
});
return <Dropdown fluid selection
placeholder='Limit'
options={limitOptions}
value={limit || limitOptions[0]['value']}
onChange={(e, {value}) => setLimit(value)}
/>
}
export function FilesView(
{
files,
@ -373,8 +364,6 @@ export function FilesView(
onSelect,
setPage,
headlines = false,
limitOptions = [12, 24, 48, 96],
showAnyTag = false,
},
) {
const {view} = useSearchView();
@ -403,15 +392,11 @@ export function FilesView(
}
const viewButton = <SearchViewButton headlines={headlines}/>
const limitDropdown = <SearchLimitDropdown limits={limitOptions}/>;
const tagQuerySelector = <TagsQuerySelector showAny={showAnyTag}/>;
return {
body,
paginator,
viewButton,
limitDropdown,
tagQuerySelector,
}
}
@ -502,33 +487,357 @@ export function TagsQuerySelector({onChange, showAny = true}) {
/>
}
export function FileSearchFilterButton({size = 'medium'}) {
// see `filterToMimetypes`
const filterOptions = [
{key: 'video', text: 'Video', value: 'video'},
{key: 'archive', text: 'Archive', value: 'archive'},
{key: 'pdf', text: 'PDF', value: 'pdf'},
{key: 'ebook', text: 'eBook', value: 'ebook'},
{key: 'doc', text: 'Document', value: 'doc'},
{key: 'audio', text: 'Audio', value: 'audio'},
{key: 'image', text: 'Image', value: 'image'},
{key: 'zip', text: 'ZIP', value: 'zip'},
{key: 'model', text: '3D Model', value: 'model'},
{key: 'software', text: 'Software', value: 'software'},
];
// see `filterToMimetypes`
export const fileMimetypeFilterOptions = [
{key: 'video', text: 'Video', value: 'video'},
{key: 'archive', text: 'Archive', value: 'archive'},
{key: 'pdf', text: 'PDF', value: 'pdf'},
{key: 'ebook', text: 'eBook', value: 'ebook'},
{key: 'doc', text: 'Document', value: 'doc'},
{key: 'audio', text: 'Audio', value: 'audio'},
{key: 'image', text: 'Image', value: 'image'},
{key: 'zip', text: 'ZIP', value: 'zip'},
{key: 'model', text: '3D Model', value: 'model'},
{key: 'software', text: 'Software', value: 'software'},
];
return <SearchFilter filters={filterOptions} size={size}/>
export const docMimetypeFilterOptions = [
{key: 'pdf', text: 'PDF', value: 'pdf'},
{key: 'epub', text: 'EPUB', value: 'epub'},
{key: 'comic', text: 'Comic', value: 'comic'},
{key: 'mobi', text: 'MOBI', value: 'mobi'},
];
export function FileSearchFilterButton({size = 'medium'}) {
return <SearchFilter filters={fileMimetypeFilterOptions} size={size}/>
}
export function DocSearchFilterButton({size = 'medium'}) {
const filterOptions = [
{key: 'pdf', text: 'PDF', value: 'pdf'},
{key: 'epub', text: 'EPUB', value: 'epub'},
{key: 'comic', text: 'Comic', value: 'comic'},
{key: 'mobi', text: 'MOBI', value: 'mobi'},
];
return <SearchFilter filters={docMimetypeFilterOptions} size={size}/>
}
return <SearchFilter filters={filterOptions} size={size}/>
// The app-wide default page size (matches usePages' fallback); used to reset Results Per Page on Clear All.
const DEFAULT_SEARCH_LIMIT = 24;
// A single section within the comprehensive SearchFilterModal. Vertical spacing between sections is
// provided by the surrounding Grid rows.
function SearchFilterSection({header, children}) {
return <>
<Header as='h4' style={{marginBottom: '0.6em'}}>{header}</Header>
{children}
</>
}
// A comprehensive modal which combines every search option (sort, tags, file-type filter, dates, limit)
// into a single dialog. Which sections appear is controlled by the props, so each page can show only what
// is relevant. All state is URL-query driven (same hooks the individual controls use), so no extra wiring
// is required on the page.
export function SearchFilterModal(
{
open,
onClose,
sorts = null,
fileFilterOptions = null,
showDates = false,
showTags = true,
showLimit = true,
limitOptions = [12, 24, 48, 96],
},
) {
// Committed (URL) state — read only; drafts below hold the user's in-progress edits.
const {filter} = useSearchFilter();
const {sort} = useSearchOrder();
const {dateRange, months} = useSearchDate();
const {activeTags, anyTag} = useSearch();
const {limit} = usePages(DEFAULT_SEARCH_LIMIT);
const {updateQuery} = useContext(QueryContext);
const emptyRange = [null, null];
const seededRange = dateRange && (dateRange[0] || dateRange[1]) ? dateRange : emptyRange;
const seededMonths = (months || []).map(i => parseInt(i));
// Every selection is drafted locally and only applied to the URL (which performs the search) when the
// modal is closed — via Done, the close icon, or clicking away.
const [draftSort, setDraftSort] = useState(null);
const [draftFilter, setDraftFilter] = useState(null);
const [draftTags, setDraftTags] = useState([]);
const [draftAnyTag, setDraftAnyTag] = useState(false);
const [draftLimit, setDraftLimit] = useState(null);
const [draftMonths, setDraftMonths] = useState([]);
const [draftRange, setDraftRange] = useState(emptyRange);
// Seed the drafts from the URL each time the modal is opened.
React.useEffect(() => {
if (open) {
setDraftSort(sort || null);
setDraftFilter(filter || null);
setDraftTags(activeTags || []);
setDraftAnyTag(anyTag || false);
setDraftLimit(limit);
setDraftMonths(seededMonths);
setDraftRange(seededRange);
}
}, [open]);
const handleClose = () => {
// Apply every changed draft in a SINGLE updateQuery — sequential setters would each merge against
// the same stale searchParams and clobber one another. Only reset pagination if something changed.
const params = {};
if (sorts && (draftSort || null) !== (sort || null)) {
params.order = draftSort;
}
if (fileFilterOptions && (draftFilter || null) !== (filter || null)) {
params.filter = draftFilter;
}
if (showTags && (draftAnyTag !== anyTag || JSON.stringify(draftTags) !== JSON.stringify(activeTags))) {
if (draftAnyTag) {
params.tag = [];
params.anyTag = 'true';
} else {
params.tag = draftTags;
params.anyTag = null;
}
}
if (showLimit && draftLimit !== limit) {
params.l = draftLimit;
}
if (showDates && (JSON.stringify(draftMonths) !== JSON.stringify(seededMonths)
|| JSON.stringify(draftRange) !== JSON.stringify(seededRange))) {
let newFromDate = null;
let newToDate = null;
if (draftRange[0] <= draftRange[1]) {
newFromDate = draftRange[0];
newToDate = draftRange[1];
}
params.fromDate = newFromDate;
params.toDate = newToDate;
params.month = draftMonths;
}
if (Object.keys(params).length > 0) {
params.o = 0;
updateQuery(params);
}
if (onClose) {
onClose();
}
}
const handleClearAll = () => {
// Reset the form only; nothing is applied until the modal is closed.
setDraftSort(null);
setDraftFilter(null);
setDraftTags([]);
setDraftAnyTag(false);
setDraftLimit(DEFAULT_SEARCH_LIMIT);
setDraftMonths([]);
setDraftRange(emptyRange);
}
// Sort section (operates on the draft).
const sortKey = draftSort ? draftSort.replace(/^-/, '') : (sorts ? sorts[0]['value'] : null);
const desc = draftSort ? draftSort.startsWith('-') : true;
const sortButtons = sorts && sorts.map(o =>
<Button key={o['value']}
color={o['value'] === sortKey ? 'violet' : undefined}
onClick={() => setDraftSort(desc ? `-${o['value']}` : o['value'])}
style={{margin: '0.2em'}}
>{o['text']}</Button>
);
const directionToggle = <Button icon labelPosition='left'
onClick={() => {
const base = sortKey || sorts[0]['value'];
setDraftSort(desc ? base : `-${base}`);
}}>
<Icon name={desc ? 'sort amount down' : 'sort amount up'}/>
{desc ? 'Descending' : 'Ascending'}
</Button>;
// File-type filter section (single-select radios; clicking the active one clears it). Laid out in
// columns so more options fit on each line.
const filterFields = fileFilterOptions && fileFilterOptions.map(i =>
<Grid.Column mobile={8} tablet={5} computer={4} key={i['value']}>
<Form.Field>
<Checkbox radio
label={i['text']}
name='searchFilterRadioGroup'
checked={draftFilter === i['value']}
value={i['value']}
onChange={() => setDraftFilter(draftFilter === i['value'] ? null : i['value'])}
/>
</Form.Field>
</Grid.Column>
);
// Results-per-page as buttons (matches the Sort By buttons and avoids a dropdown menu being clipped by
// the scrolling modal content).
const limitButtons = limitOptions.map(i =>
<Button key={i}
color={(draftLimit || limitOptions[0]) === i ? 'violet' : undefined}
onClick={() => setDraftLimit(i)}
style={{margin: '0.2em'}}
>{i}</Button>
);
return <Modal open={open} onClose={handleClose} closeIcon size='small'>
<Modal.Header>Search Filters</Modal.Header>
<Modal.Content scrolling>
<Grid>
{sorts &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Sort By'>
<div style={{marginBottom: '1em'}}>{directionToggle}</div>
<div>{sortButtons}</div>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
{fileFilterOptions &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='File Type'>
<Form><Grid>{filterFields}</Grid></Form>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
{(showTags || showLimit) &&
<Grid.Row columns={2}>
{showTags &&
<Grid.Column>
<SearchFilterSection header='Tags'>
<TagsSelector hideGroup hideEdit showAny
active={draftAnyTag || (draftTags && draftTags.length > 0)}
selectedTagNames={draftTags}
anyTag={draftAnyTag}
filterByOverlap={true}
onChange={(tagNames, newAnyTag) => {
if (newAnyTag) {
setDraftAnyTag(true);
setDraftTags([]);
} else {
setDraftAnyTag(false);
setDraftTags(tagNames);
}
}}
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
/>
</SearchFilterSection>
</Grid.Column>}
{showLimit &&
<Grid.Column>
<SearchFilterSection header='Results Per Page'>
<div>{limitButtons}</div>
</SearchFilterSection>
</Grid.Column>}
</Grid.Row>}
{showDates &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Published Date'>
<MonthsForm monthsSelected={draftMonths} setMonthsSelected={setDraftMonths}/>
<Divider/>
<DateRangeForm dateRange={draftRange} setDateRange={setDraftRange}/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
</Grid>
</Modal.Content>
<Modal.Actions>
<Button secondary floated='left' onClick={handleClearAll}>Clear All</Button>
<Button primary onClick={handleClose}>Done</Button>
</Modal.Actions>
</Modal>
}
// The trigger button which opens the comprehensive SearchFilterModal. Turns violet and displays a count
// badge when any of its filters are active.
export function SearchFilterButton(
{
sorts = null,
fileFilterOptions = null,
showDates = false,
showTags = true,
showLimit = true,
limitOptions = [12, 24, 48, 96],
size = 'medium',
content = 'Filter',
},
) {
const [open, setOpen] = useState(false);
const {filter} = useSearchFilter();
const {sort} = useSearchOrder();
const {dateRange, months} = useSearchDate();
const {activeTags, anyTag} = useSearch();
let count = 0;
if (fileFilterOptions && filter) {
count += 1;
}
if (sorts && sort) {
count += 1;
}
if (showTags && ((activeTags && activeTags.length > 0) || anyTag)) {
count += 1;
}
if (showDates && ((months && months.length > 0) || !dateRangeIsEmpty(dateRange))) {
count += 1;
}
const active = count > 0;
return <>
<Button color={active ? 'violet' : 'grey'} size={size} onClick={() => setOpen(true)}>
<Icon name='filter'/>
{content}
{count > 0 &&
<Label circular size='tiny' style={{marginLeft: '0.7em'}}>{count}</Label>}
</Button>
<SearchFilterModal
open={open}
onClose={() => setOpen(false)}
sorts={sorts}
fileFilterOptions={fileFilterOptions}
showDates={showDates}
showTags={showTags}
showLimit={showLimit}
limitOptions={limitOptions}
/>
</>
}
// The single-line search control row shared by the Videos/Archives/Docs result pages:
// [ view toggle ] [ search input (grows) ] [ Filter button ]
// The text input is managed locally and only submits its value on enter/clear. Filter options are
// forwarded to the SearchFilterButton so each page shows only the sections relevant to it.
export function SearchControlBar(
{
searchStr,
setSearchStr,
placeholder = 'Search...',
inputRef,
viewButton,
sorts = null,
fileFilterOptions = null,
showDates = false,
},
) {
const [localSearchStr, setLocalSearchStr] = useState(searchStr || '');
const searchInput = <SearchInput
searchStr={localSearchStr}
onChange={setLocalSearchStr}
onClear={() => setLocalSearchStr(null)}
onSubmit={setSearchStr}
placeholder={placeholder}
inputRef={inputRef}
/>;
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em', marginBottom: '1em'}}>
{viewButton}
<div style={{flexGrow: 1, minWidth: 0}}>{searchInput}</div>
<SearchFilterButton sorts={sorts} fileFilterOptions={fileFilterOptions} showDates={showDates}/>
</div>
}
export function FilesSearchView({
@ -539,7 +848,7 @@ export function FilesSearchView({
const {searchFiles, pages} = useSearchFiles(24, emptySearch, model);
const {body, paginator, viewButton, limitDropdown, tagQuerySelector} = FilesView(
const {body, paginator, viewButton} = FilesView(
{
files: searchFiles,
activePage: pages.activePage,
@ -549,31 +858,11 @@ export function FilesSearchView({
onSelect: null,
setPage: pages.setPage,
headlines: true,
showAnyTag: true,
},
);
return <>
<Media at='mobile'>
<Grid>
<Grid.Row>
{showView &&
<Grid.Column width={2}>{viewButton}</Grid.Column>}
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<Media greaterThanOrEqual='tablet'>
<Grid>
<Grid.Row>
{showView &&
<Grid.Column width={1}>{viewButton}</Grid.Column>}
<Grid.Column width={2}>{limitDropdown}</Grid.Column>
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
{showView && <div style={{marginBottom: '1em'}}>{viewButton}</div>}
{body}
{paginator}
</>

View file

@ -1,6 +1,6 @@
import React, {useState} from "react";
import {Link, Route, Routes, useLocation, useNavigate} from "react-router";
import {FilesSearchView} from "./Files";
import {FilesSearchView, SearchViewButton} from "./Files";
import {useLatestRequest, usePages, useSearchChannels, useSearchDate, useSearchFilter} from "../hooks/customHooks";
import {ShortcutHint} from "./ShortcutHint";
import {ZimSearchView} from "./Zim";
@ -485,10 +485,13 @@ export function SearchView({suggestions, suggestionsSums, loading}) {
{text: othersTabName, to: `/search/other${search}`, key: 'othersSearch'},
];
// The view toggle only applies to the Files tab; show it in the tab row's right slot there.
const isFilesTab = !/\/(zim|map|other)$/.test(location.pathname);
return <React.Fragment>
<TabLinks links={links}/>
<TabLinks links={links} right={isFilesTab ? <SearchViewButton headlines/> : null}/>
<Routes>
<Route path='/*' element={<FilesSearchView/>}/>
<Route path='/*' element={<FilesSearchView showView={false}/>}/>
<Route path='/zim' exact element={<ZimSearchView suggestions={suggestions} loading={loading}/>}/>
<Route path='/map' exact element={<MapSearchView/>}/>
<Route path='/other' exact element={<OtherSearchView loading={loading}/>}/>

View file

@ -0,0 +1,129 @@
import React from 'react';
import {fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router';
import {SearchFilterButton} from './Files';
import {QueryContext, ThemeContext} from '../contexts/contexts';
// Render SearchFilterButton with a controlled QueryContext so the search hooks
// (useSearchFilter/useSearchOrder/useSearchDate/useSearch) can read the URL
// query params without a real router/provider.
function renderButton(props = {}, initialParams = {}, onUpdateQuery = null) {
const params = new URLSearchParams(initialParams);
function Wrapper({children}) {
const [searchParams, setSearchParams] = React.useState(params);
const updateQuery = (newParams) => {
if (onUpdateQuery) {
onUpdateQuery(newParams);
}
const next = new URLSearchParams(searchParams.toString());
Object.entries(newParams).forEach(([k, v]) => {
if (v === null || v === undefined) {
next.delete(k);
} else if (Array.isArray(v)) {
next.delete(k);
v.forEach(i => next.append(k, String(i)));
} else {
next.set(k, String(v));
}
});
setSearchParams(next);
};
const theme = {
i: {}, s: {}, t: {}, theme: 'light', inverted: false, setInverted: () => {},
};
return (
<MemoryRouter>
<ThemeContext.Provider value={theme}>
<QueryContext.Provider value={{searchParams, updateQuery, getLocationStr: () => '/'}}>
{children}
</QueryContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
);
}
return render(<Wrapper><SearchFilterButton {...props}/></Wrapper>);
}
const videoOrders = [
{value: 'published_datetime', text: 'Published Date'},
{value: 'size', text: 'Size'},
];
describe('SearchFilterButton', () => {
it('renders a Filter button with no count badge when nothing is active', () => {
renderButton({sorts: videoOrders});
expect(screen.getByText('Filter')).toBeInTheDocument();
// No active filters -> no numeric badge.
expect(screen.queryByText('1')).toBeNull();
});
it('shows a count badge summing the active filters', () => {
// order + filter + tag => count of 3.
renderButton(
{sorts: videoOrders, fileFilterOptions: [{value: 'video', text: 'Video'}]},
{order: '-published_datetime', filter: 'video', tag: 'Food'},
);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('opens the modal and only shows the sections enabled by props', () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
// Enabled sections.
expect(screen.getByText('Sort By')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
expect(screen.getByText('Results Per Page')).toBeInTheDocument();
// Disabled sections (no fileFilterOptions, showDates defaults false). Assert on the date range
// 'From Year' label, since the 'Published Date' header text collides with a sort option label.
expect(screen.queryByText('File Type')).toBeNull();
expect(screen.queryByText('From Year')).toBeNull();
});
it('applies changes only on close, not as fields change', () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {}, spy);
fireEvent.click(screen.getByText('Filter'));
// Changing the sort field updates the draft but must NOT perform the search yet.
fireEvent.click(screen.getByText('Size'));
expect(spy).not.toHaveBeenCalled();
// Closing via Done commits the draft in a single query update.
fireEvent.click(screen.getByText('Done'));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toMatchObject({order: '-size'});
});
it('Clear All resets results-per-page to the default on close', () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {l: '96'}, spy);
fireEvent.click(screen.getByText('Filter'));
fireEvent.click(screen.getByText('Clear All'));
fireEvent.click(screen.getByText('Done'));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toMatchObject({l: 24});
});
it('does not update the query when closed without changes', () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {}, spy);
fireEvent.click(screen.getByText('Filter'));
fireEvent.click(screen.getByText('Done'));
expect(spy).not.toHaveBeenCalled();
});
it('shows the File Type section when fileFilterOptions are provided', () => {
renderButton({
sorts: videoOrders,
fileFilterOptions: [{value: 'pdf', text: 'PDF'}, {value: 'epub', text: 'EPUB'}],
});
fireEvent.click(screen.getByText('Filter'));
expect(screen.getByText('File Type')).toBeInTheDocument();
expect(screen.getByText('PDF')).toBeInTheDocument();
expect(screen.getByText('EPUB')).toBeInTheDocument();
});
});

View file

@ -19,9 +19,7 @@ import {
PageContainer,
PreviewLink,
scrollToTop,
SearchInput,
secondsToFullDuration,
SortButton,
TabLinks,
textEllipsis,
useTitle,
@ -47,7 +45,7 @@ import {
TableCell
} from "semantic-ui-react";
import {useChannel, useSearchOrder, useSearchVideos, useVideo, useVideoStatistics} from "../hooks/customHooks";
import {FileRowTagIcon, FilesView} from "./Files";
import {FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
import {Button, Card, Header, Loader, Modal, Placeholder, Popup, Segment, Statistic} from "./Theme";
@ -65,7 +63,7 @@ import {
updateVideoDownloaderConfig,
uploadCookies,
} from "../api";
import {Media, QueryContext, ThemeContext} from "../contexts/contexts";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import _ from "lodash";
import {defaultFileOrder, defaultSearchOrder} from "./Vars";
import {InputForm, ToggleForm, useForm} from "../hooks/useForm";
@ -219,7 +217,7 @@ export function VideosPage() {
/>
</div>;
const {body, paginator, viewButton, limitDropdown, tagQuerySelector} = FilesView(
const {body, paginator, viewButton} = FilesView(
{
files: videos,
activePage: activePage,
@ -233,42 +231,16 @@ export function VideosPage() {
},
);
const [localSearchStr, setLocalSearchStr] = React.useState(searchStr || '');
const searchInput = <SearchInput
searchStr={localSearchStr}
onChange={setLocalSearchStr}
onClear={() => setLocalSearchStr(null)}
onSubmit={setSearchStr}
placeholder='Search Videos...'
inputRef={searchInputRef}
/>;
return <>
{header}
<Media at='mobile'>
<Grid>
<Grid.Row>
<Grid.Column width={2}>{viewButton}</Grid.Column>
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
<Grid.Column width={8}><SortButton sorts={videoOrders}/></Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column width={16}>{searchInput}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<Media greaterThanOrEqual='tablet'>
<Grid>
<Grid.Row>
<Grid.Column width={1}>{viewButton}</Grid.Column>
<Grid.Column width={2}>{limitDropdown}</Grid.Column>
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
<Grid.Column width={4}><SortButton sorts={videoOrders}/></Grid.Column>
<Grid.Column width={8}>{searchInput}</Grid.Column>
</Grid.Row>
</Grid>
</Media>
<SearchControlBar
searchStr={searchStr}
setSearchStr={setSearchStr}
placeholder='Search Videos...'
inputRef={searchInputRef}
viewButton={viewButton}
sorts={videoOrders}
/>
{body}
{paginator}
<TaggedDeleteConfirmModal