Filter issue fixes (#5126)

* Fix filter reading from URL when not active
* Use alternative clone mechanism. Fixes weird filter hook behaviour
* Separate search term input component
This commit is contained in:
WithoutPants
2024-08-12 14:10:10 +10:00
committed by GitHub
parent aa1894964f
commit c47aafff66
12 changed files with 134 additions and 154 deletions

View File

@@ -65,7 +65,7 @@ export const SetFilterURL = (props: {
const { setFilter } = useFilterURL(filter, setFilterOrig, { const { setFilter } = useFilterURL(filter, setFilterOrig, {
defaultFilter, defaultFilter,
setURL, active: setURL,
}); });
return ( return (

View File

@@ -1,6 +1,5 @@
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { SortDirectionEnum } from "src/core/generated-graphql"; import { SortDirectionEnum } from "src/core/generated-graphql";
import { import {
@@ -11,7 +10,6 @@ import {
OverlayTrigger, OverlayTrigger,
Tooltip, Tooltip,
InputGroup, InputGroup,
FormControl,
Popover, Popover,
Overlay, Overlay,
} from "react-bootstrap"; } from "react-bootstrap";
@@ -26,11 +24,83 @@ import {
faCaretUp, faCaretUp,
faCheck, faCheck,
faRandom, faRandom,
faTimes,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FilterButton } from "./Filters/FilterButton"; import { FilterButton } from "./Filters/FilterButton";
import { useDebounce } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
import { View } from "./views"; import { View } from "./views";
import { ClearableInput } from "../Shared/ClearableInput";
export function useDebouncedSearchInput(
filter: ListFilterModel,
setFilter: (filter: ListFilterModel) => void
) {
const callback = useCallback(
(value: string) => {
const newFilter = filter.clone();
newFilter.searchTerm = value;
newFilter.currentPage = 1;
setFilter(newFilter);
},
[filter, setFilter]
);
const onClear = useCallback(() => callback(""), [callback]);
const searchCallback = useDebounce(callback, 500);
return { searchCallback, onClear };
}
export const SearchTermInput: React.FC<{
filter: ListFilterModel;
onFilterUpdate: (newFilter: ListFilterModel) => void;
}> = ({ filter, onFilterUpdate }) => {
const intl = useIntl();
const [localInput, setLocalInput] = useState(filter.searchTerm);
const focus = useFocus();
const [, setQueryFocus] = focus;
useEffect(() => {
setLocalInput(filter.searchTerm);
}, [filter.searchTerm]);
const { searchCallback, onClear } = useDebouncedSearchInput(
filter,
onFilterUpdate
);
useEffect(() => {
Mousetrap.bind("/", (e) => {
setQueryFocus();
e.preventDefault();
});
return () => {
Mousetrap.unbind("/");
};
});
function onSetQuery(value: string) {
setLocalInput(value);
if (!value) {
onClear();
}
searchCallback(value);
}
return (
<ClearableInput
className="search-term-input"
focus={focus}
value={localInput}
setValue={onSetQuery}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
/>
);
};
interface IListFilterProps { interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void; onFilterUpdate: (newFilter: ListFilterModel) => void;
@@ -48,44 +118,17 @@ export const ListFilter: React.FC<IListFilterProps> = ({
view, view,
}) => { }) => {
const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false); const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false);
const [queryRef, setQueryFocus] = useFocus();
const [queryClearShowing, setQueryClearShowing] = useState(
!!filter.searchTerm
);
const perPageSelect = useRef(null); const perPageSelect = useRef(null);
const [perPageInput, perPageFocus] = useFocus(); const [perPageInput, perPageFocus] = useFocus();
const filterOptions = filter.options; const filterOptions = filter.options;
const searchQueryUpdated = useCallback(
(value: string) => {
const newFilter = cloneDeep(filter);
newFilter.searchTerm = value;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
},
[filter, onFilterUpdate]
);
const searchCallback = useDebounce((value: string) => {
const newFilter = cloneDeep(filter);
newFilter.searchTerm = value;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
}, 500);
const intl = useIntl(); const intl = useIntl();
useEffect(() => { useEffect(() => {
Mousetrap.bind("/", (e) => {
setQueryFocus();
e.preventDefault();
});
Mousetrap.bind("r", () => onReshuffleRandomSort()); Mousetrap.bind("r", () => onReshuffleRandomSort());
return () => { return () => {
Mousetrap.unbind("/");
Mousetrap.unbind("r"); Mousetrap.unbind("r");
}; };
}); });
@@ -96,14 +139,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
} }
}, [customPageSizeShowing, perPageFocus]); }, [customPageSizeShowing, perPageFocus]);
// clear search input when filter is cleared
useEffect(() => {
if (!filter.searchTerm) {
if (queryRef.current) queryRef.current.value = "";
setQueryClearShowing(false);
}
}, [filter.searchTerm, queryRef]);
function onChangePageSize(val: string) { function onChangePageSize(val: string) {
if (val === "custom") { if (val === "custom") {
// added timeout since Firefox seems to trigger the rootClose immediately // added timeout since Firefox seems to trigger the rootClose immediately
@@ -125,18 +160,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
onFilterUpdate(newFilter); onFilterUpdate(newFilter);
} }
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
searchCallback(event.currentTarget.value);
setQueryClearShowing(!!event.currentTarget.value);
}
function onClearQuery() {
if (queryRef.current) queryRef.current.value = "";
searchQueryUpdated("");
setQueryFocus();
setQueryClearShowing(false);
}
function onChangeSortDirection() { function onChangeSortDirection() {
const newFilter = cloneDeep(filter); const newFilter = cloneDeep(filter);
if (filter.sortDirection === SortDirectionEnum.Asc) { if (filter.sortDirection === SortDirectionEnum.Asc) {
@@ -209,27 +232,8 @@ export const ListFilter: React.FC<IListFilterProps> = ({
return ( return (
<> <>
<div className="mb-2 mr-2 d-flex"> <div className="mb-2 d-flex">
<div className="flex-grow-1 query-text-field-group"> <SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
<FormControl
ref={queryRef}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
defaultValue={filter.searchTerm}
onInput={onChangeQuery}
className="query-text-field bg-secondary text-white border-secondary"
/>
<Button
variant="secondary"
onClick={onClearQuery}
title={intl.formatMessage({ id: "actions.clear" })}
className={cx(
"query-text-field-clear",
queryClearShowing ? "" : "d-none"
)}
>
<Icon icon={faTimes} />
</Button>
</div>
</div> </div>
<ButtonGroup className="mr-2 mb-2"> <ButtonGroup className="mr-2 mb-2">

View File

@@ -571,3 +571,7 @@ input[type="range"].zoom-slider {
border-left: none; border-left: none;
} }
} }
.search-term-input {
margin-right: 0.5rem;
}

View File

@@ -13,10 +13,10 @@ export function useFilterURL(
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>, setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>,
options?: { options?: {
defaultFilter?: ListFilterModel; defaultFilter?: ListFilterModel;
setURL?: boolean; active?: boolean;
} }
) { ) {
const { defaultFilter, setURL = true } = options ?? {}; const { defaultFilter, active = true } = options ?? {};
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
@@ -28,7 +28,7 @@ export function useFilterURL(
) => { ) => {
const newFilter = isFunction(value) ? value(filter) : value; const newFilter = isFunction(value) ? value(filter) : value;
if (setURL) { if (active) {
const newParams = newFilter.makeQueryParameters(); const newParams = newFilter.makeQueryParameters();
history.replace({ ...history.location, search: newParams }); history.replace({ ...history.location, search: newParams });
} else { } else {
@@ -36,12 +36,15 @@ export function useFilterURL(
setFilter(newFilter); setFilter(newFilter);
} }
}, },
[history, setURL, setFilter, filter] [history, active, setFilter, filter]
); );
// This hook runs on every page location change (ie navigation), // This hook runs on every page location change (ie navigation),
// and updates the filter accordingly. // and updates the filter accordingly.
useEffect(() => { useEffect(() => {
// don't apply if active is false
if (!active) return;
// re-init to load default filter on empty new query params // re-init to load default filter on empty new query params
if (!location.search) { if (!location.search) {
if (defaultFilter) updateFilter(defaultFilter.clone()); if (defaultFilter) updateFilter(defaultFilter.clone());
@@ -58,7 +61,7 @@ export function useFilterURL(
return prevFilter; return prevFilter;
} }
}); });
}, [location.search, defaultFilter, setFilter, updateFilter]); }, [active, location.search, defaultFilter, setFilter, updateFilter]);
return { setFilter: updateFilter }; return { setFilter: updateFilter };
} }

View File

@@ -4,8 +4,10 @@ import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import useFocus from "src/utils/focus"; import useFocus from "src/utils/focus";
import cx from "classnames";
interface IClearableInput { interface IClearableInput {
className?: string;
value: string; value: string;
setValue: (value: string) => void; setValue: (value: string) => void;
focus?: ReturnType<typeof useFocus>; focus?: ReturnType<typeof useFocus>;
@@ -13,6 +15,7 @@ interface IClearableInput {
} }
export const ClearableInput: React.FC<IClearableInput> = ({ export const ClearableInput: React.FC<IClearableInput> = ({
className,
value, value,
setValue, setValue,
focus, focus,
@@ -37,7 +40,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
} }
return ( return (
<div className="clearable-input-group"> <div className={cx("clearable-input-group", className)}>
<FormControl <FormControl
ref={queryRef} ref={queryRef}
placeholder={placeholder} placeholder={placeholder}

View File

@@ -492,12 +492,14 @@ div.react-datepicker {
.clearable-text-field-clear { .clearable-text-field-clear {
background-color: $secondary; background-color: $secondary;
bottom: 0;
color: $muted-gray; color: $muted-gray;
font-size: 0.875rem; font-size: 0.875rem;
margin: 0.375rem 0.75rem; margin: 0.375rem 0.75rem;
padding: 0; padding: 0;
position: absolute; position: absolute;
right: 0; right: 0;
top: 0;
z-index: 4; z-index: 4;
&:hover, &:hover,

View File

@@ -89,7 +89,13 @@ export abstract class Criterion<V extends CriterionValue> {
this.value = value; this.value = value;
} }
public abstract clone(): Criterion<V>; public clone() {
const ret = Object.assign(Object.create(Object.getPrototypeOf(this)), this);
ret.cloneValues();
return ret;
}
protected cloneValues() {}
public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) { public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) {
const modifierMessageID = modifierMessageIDs[modifier]; const modifierMessageID = modifierMessageIDs[modifier];
@@ -257,13 +263,8 @@ export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
super(type, value); super(type, value);
} }
public clone(): Criterion<ILabeledId[]> { public cloneValues() {
const newCriterion = new ILabeledIdCriterion( this.value = this.value.map((v) => ({ ...v }));
this.criterionOption,
this.value.map((v) => ({ ...v }))
);
newCriterion.modifier = this.modifier;
return newCriterion;
} }
protected getLabelValue(_intl: IntlShape): string { protected getLabelValue(_intl: IntlShape): string {
@@ -301,17 +302,12 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
super(type, value); super(type, value);
} }
public clone(): Criterion<IHierarchicalLabelValue> { public cloneValues() {
const newCriterion = new IHierarchicalLabeledIdCriterion( this.value = {
this.criterionOption,
{
...this.value, ...this.value,
items: this.value.items.map((v) => ({ ...v })), items: this.value.items.map((v) => ({ ...v })),
excluded: this.value.excluded.map((v) => ({ ...v })), excluded: this.value.excluded.map((v) => ({ ...v })),
} };
);
newCriterion.modifier = this.modifier;
return newCriterion;
} }
override get modifier(): CriterionModifier { override get modifier(): CriterionModifier {
@@ -512,13 +508,6 @@ export class StringCriterion extends Criterion<string> {
super(type, ""); super(type, "");
} }
public clone() {
const newCriterion = new StringCriterion(this.criterionOption);
newCriterion.modifier = this.modifier;
newCriterion.value = this.value;
return newCriterion;
}
protected getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {
return this.value; return this.value;
} }
@@ -532,18 +521,13 @@ export class StringCriterion extends Criterion<string> {
} }
} }
export class MultiStringCriterion extends Criterion<string[]> { export abstract class MultiStringCriterion extends Criterion<string[]> {
constructor(type: CriterionOption, value: string[] = []) { constructor(type: CriterionOption, value: string[] = []) {
super(type, value); super(type, value);
} }
public clone(): Criterion<string[]> { public cloneValues() {
const newCriterion = new MultiStringCriterion( this.value = this.value.slice();
this.criterionOption,
this.value.slice()
);
newCriterion.modifier = this.modifier;
return newCriterion;
} }
protected getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {
@@ -718,11 +702,8 @@ export class NumberCriterion extends Criterion<INumberValue> {
super(type, { value: undefined, value2: undefined }); super(type, { value: undefined, value2: undefined });
} }
public clone() { public cloneValues() {
const newCriterion = new NumberCriterion(this.criterionOption); this.value = { ...this.value };
newCriterion.modifier = this.modifier;
newCriterion.value = { ...this.value };
return newCriterion;
} }
public get value(): INumberValue { public get value(): INumberValue {
@@ -803,11 +784,8 @@ export class DurationCriterion extends Criterion<INumberValue> {
super(type, { value: undefined, value2: undefined }); super(type, { value: undefined, value2: undefined });
} }
public clone() { public cloneValues() {
const newCriterion = new DurationCriterion(this.criterionOption); this.value = { ...this.value };
newCriterion.modifier = this.modifier;
newCriterion.value = { ...this.value };
return newCriterion;
} }
public toCriterionInput(): IntCriterionInput { public toCriterionInput(): IntCriterionInput {
@@ -887,11 +865,8 @@ export class DateCriterion extends Criterion<IDateValue> {
super(type, { value: "", value2: undefined }); super(type, { value: "", value2: undefined });
} }
public clone() { public cloneValues() {
const newCriterion = new DateCriterion(this.criterionOption); this.value = { ...this.value };
newCriterion.modifier = this.modifier;
newCriterion.value = { ...this.value };
return newCriterion;
} }
public encodeValue() { public encodeValue() {
@@ -993,11 +968,8 @@ export class TimestampCriterion extends Criterion<ITimestampValue> {
super(type, { value: "", value2: undefined }); super(type, { value: "", value2: undefined });
} }
public clone() { public cloneValues() {
const newCriterion = new TimestampCriterion(this.criterionOption); this.value = { ...this.value };
newCriterion.modifier = this.modifier;
newCriterion.value = { ...this.value };
return newCriterion;
} }
public encodeValue() { public encodeValue() {

View File

@@ -25,8 +25,8 @@ export const GenderCriterionOption = new CriterionOption({
}); });
export class GenderCriterion extends MultiStringCriterion { export class GenderCriterion extends MultiStringCriterion {
constructor() { constructor(value: string[] = []) {
super(GenderCriterionOption); super(GenderCriterionOption, value);
} }
public toCriterionInput(): GenderCriterionInput { public toCriterionInput(): GenderCriterionInput {

View File

@@ -33,11 +33,12 @@ export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
super(PerformersCriterionOption, { items: [], excluded: [] }); super(PerformersCriterionOption, { items: [], excluded: [] });
} }
public clone() { public cloneValues() {
const newCriterion = new PerformersCriterion(); this.value = {
newCriterion.modifier = this.modifier; ...this.value,
newCriterion.value = { ...this.value }; items: this.value.items.map((v) => ({ ...v })),
return newCriterion; excluded: this.value.excluded.map((v) => ({ ...v })),
};
} }
override get modifier(): CriterionModifier { override get modifier(): CriterionModifier {

View File

@@ -29,11 +29,8 @@ export class PhashCriterion extends Criterion<IPhashDistanceValue> {
super(PhashCriterionOption, { value: "", distance: 0 }); super(PhashCriterionOption, { value: "", distance: 0 });
} }
public clone() { public cloneValues() {
const newCriterion = new PhashCriterion(); this.value = { ...this.value };
newCriterion.modifier = this.modifier;
newCriterion.value = { ...this.value };
return newCriterion;
} }
protected getLabelValue() { protected getLabelValue() {

View File

@@ -45,11 +45,8 @@ export class RatingCriterion extends Criterion<INumberValue> {
this.ratingSystem = ratingSystem; this.ratingSystem = ratingSystem;
} }
public clone() { public cloneValues() {
const newCriterion = new RatingCriterion(this.ratingSystem); this.value = { ...this.value };
newCriterion.modifier = this.modifier;
newCriterion.value = { ...this.value };
return newCriterion;
} }
public get value(): INumberValue { public get value(): INumberValue {

View File

@@ -27,11 +27,8 @@ export class StashIDCriterion extends Criterion<IStashIDValue> {
}); });
} }
public clone() { public cloneValues() {
const newCriterion = new StashIDCriterion(); this.value = { ...this.value };
newCriterion.modifier = this.modifier;
newCriterion.value = { ...this.value };
return newCriterion;
} }
public get value(): IStashIDValue { public get value(): IStashIDValue {