in translate/src/modules/search/components/SearchBox.tsx [89:394]
export function SearchBoxBase({
dispatch,
parameters,
project,
searchAndFilters,
}: InternalProps): React.ReactElement<'div'> {
const { checkUnsavedChanges } = useContext(UnsavedActions);
const applyOnChange = useRef(false);
const searchInput = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [timeRange, setTimeRange] = useReducer(
(_prev: unknown, value: string | null | undefined) =>
value ? getTimeRangeFromURL(value) : null,
null,
);
const [filters, updateFilters] = useReducer(
(state: FilterState, action: FilterAction[]) => {
const next = { ...state };
for (const { filter, value } of action) {
next[filter] = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(',')
: [];
}
return next;
},
{
authors: [],
extras: [],
statuses: [],
tags: [],
},
);
const [searchOptions, updateSearchOptions] = useReducer(
(state: SearchState, action: SearchAction[]) => {
const next = { ...state };
for (const { searchOption, value } of action) {
next[searchOption] = value ?? false;
}
return next;
},
{
search_identifiers: false,
search_exclude_source_strings: false,
search_rejected_translations: false,
search_match_case: false,
search_match_whole_word: false,
},
);
useEffect(() => {
const handleShortcuts = (ev: KeyboardEvent) => {
// On Ctrl + Shift + F, set focus on the search input.
if (ev.key === 'F' && !ev.altKey && ev.ctrlKey && ev.shiftKey) {
ev.preventDefault();
searchInput.current?.focus();
}
};
document.addEventListener('keydown', handleShortcuts);
return () => document.removeEventListener('keydown', handleShortcuts);
}, []);
const updateFiltersFromURL = useCallback(() => {
const { author, extra, status, tag, time } = parameters;
updateFilters([
{ filter: 'authors', value: author },
{ filter: 'extras', value: extra },
{ filter: 'statuses', value: status },
{ filter: 'tags', value: tag },
]);
setTimeRange(time);
}, [parameters]);
const updateOptionsFromURL = useCallback(() => {
const {
search_identifiers,
search_exclude_source_strings,
search_rejected_translations,
search_match_case,
search_match_whole_word,
time,
} = parameters;
updateSearchOptions([
{ searchOption: 'search_identifiers', value: search_identifiers },
{
searchOption: 'search_exclude_source_strings',
value: search_exclude_source_strings,
},
{
searchOption: 'search_rejected_translations',
value: search_rejected_translations,
},
{
searchOption: 'search_match_case',
value: search_match_case,
},
{
searchOption: 'search_match_whole_word',
value: search_match_whole_word,
},
]);
setTimeRange(time);
}, [parameters]);
// When the URL changes, for example from links in the ResourceProgress
// component, reload the filters and search options from the URL parameters.
useEffect(updateFiltersFromURL, [parameters]);
useEffect(updateOptionsFromURL, [parameters]);
const mounted = useRef(false);
useEffect(() => {
// On mount, update the search input content based on URL.
// This is not in the `updateFiltersFromURLParams` method because we want to
// do this *only* on mount. The behavior is slightly different on update.
if (mounted.current) {
if (parameters.search == null) {
setSearch('');
}
} else {
setSearch(parameters.search ?? '');
mounted.current = true;
}
}, [parameters.search]);
const toggleFilter = useCallback(
(value: string, filter: FilterType) => {
const next = [...filters[filter]];
const prev = next.indexOf(value);
if (prev === -1) {
next.push(value);
} else {
next.splice(prev, 1);
}
updateFilters([{ filter, value: next }]);
},
[filters],
);
const toggleOption = useCallback(
(searchOption: SearchType) => {
const next = !searchOptions[searchOption];
updateSearchOptions([{ searchOption, value: next }]);
},
[searchOptions],
);
const resetFilters = useCallback(() => {
updateFilters([
{ filter: 'authors', value: [] },
{ filter: 'extras', value: [] },
{ filter: 'statuses', value: [] },
{ filter: 'tags', value: [] },
]);
setTimeRange(null);
}, []);
const applySingleFilter = useCallback(
(value: string, filter: FilterType | 'timeRange') => {
resetFilters();
applyOnChange.current = true;
if (filter === 'timeRange') {
setTimeRange(value);
} else {
updateFilters([{ filter, value }]);
}
},
[],
);
const handleGetAuthorsAndTimeRangeData = useCallback(() => {
const { locale, project, resource } = parameters;
dispatch(getAuthorsAndTimeRangeData(locale, project, resource));
}, [parameters]);
const applyOptions = useCallback(
() =>
checkUnsavedChanges(() => {
const {
search_identifiers,
search_exclude_source_strings,
search_rejected_translations,
search_match_case,
search_match_whole_word,
} = searchOptions;
dispatch(resetEntities());
parameters.push({
...parameters, // Persist all other variables to next state
search,
search_identifiers: search_identifiers,
search_exclude_source_strings: search_exclude_source_strings,
search_rejected_translations: search_rejected_translations,
search_match_case: search_match_case,
search_match_whole_word: search_match_whole_word,
entity: 0, // With the new results, the current entity might not be available anymore.
});
}),
[dispatch, parameters, search, searchOptions],
);
const applyFilters = useCallback(
() =>
checkUnsavedChanges(() => {
const { authors, extras, statuses, tags } = filters;
let status: string | null = statuses.join(',');
if (status === 'all') {
status = null;
}
dispatch(resetEntities());
parameters.push({
author: authors.join(','),
extra: extras.join(','),
search,
status,
tag: tags.join(','),
time: timeRange ? `${timeRange.from}-${timeRange.to}` : null,
entity: 0, // With the new results, the current entity might not be available anymore.
list: null,
});
}),
[dispatch, parameters, search, filters],
);
useEffect(() => {
if (applyOnChange.current) {
applyOnChange.current = false;
applyFilters();
}
}, [filters, timeRange]);
const placeholder = useMemo(() => {
const { authors, extras, statuses, tags } = filters;
const selected: string[] = [];
for (const { name, slug } of FILTERS_STATUS) {
if (statuses.includes(slug)) {
selected.push(name);
}
}
for (const { name, slug } of FILTERS_EXTRA) {
if (extras.includes(slug)) {
selected.push(name);
}
}
for (const { name, slug } of project.tags) {
if (tags.includes(slug)) {
selected.push(name);
}
}
if (timeRange) {
selected.push('Time Range');
}
for (const { display_name, email } of searchAndFilters.authors) {
if (authors.includes(email)) {
selected.push(`${display_name}'s translations`);
}
}
const str = selected.length > 0 ? selected.join(', ') : 'All';
return `Search in ${str}`;
}, [filters, project.tags, searchAndFilters.authors, timeRange]);
return (
<div className='search-box clearfix'>
<input
id='search'
ref={searchInput}
autoComplete='off'
placeholder={placeholder}
title='Search Strings (Ctrl + Shift + F)'
type='search'
value={search}
onChange={(ev) => setSearch(ev.currentTarget.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
applyFilters();
applyOptions();
}
}}
/>
<FiltersPanel
filters={filters}
tagsData={project.tags}
timeRange={timeRange}
timeRangeData={searchAndFilters.countsPerMinute}
authorsData={searchAndFilters.authors}
parameters={parameters}
applyFilters={applyFilters}
applySingleFilter={applySingleFilter}
getAuthorsAndTimeRangeData={handleGetAuthorsAndTimeRangeData}
resetFilters={resetFilters}
toggleFilter={toggleFilter}
setTimeRange={setTimeRange}
updateFiltersFromURL={updateFiltersFromURL}
/>
<SearchPanel
searchOptions={searchOptions}
applyOptions={applyOptions}
toggleOption={toggleOption}
/>
</div>
);
}