Filter tweaks (#3772)

* Use debounce hook
* Wait until search request complete before refreshing results
* Add back null modifiers
* Convert old excludes criterion to includes criterion
* Display criteria with only excludes items as excludes
* Fix depth display
* Reset search after selection
* Add back is modifier to tag filter
* Focus the input dialog after select/unselect
* Update unsupported modifiers
This commit is contained in:
DingDongSoLong4
2023-06-06 06:10:14 +02:00
committed by GitHub
parent de4237e626
commit 09df203bcf
18 changed files with 344 additions and 216 deletions

View File

@@ -77,17 +77,15 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
return (
<Form.Group className="modifier-options">
{modifierOptions.map((c) => (
{modifierOptions.map((m) => (
<Button
className={cx("modifier-option", {
selected: criterion.modifier === c.value,
selected: criterion.modifier === m,
})}
key={c.value}
onClick={() =>
onChangedModifierSelect(c.value as CriterionModifier)
}
key={m}
onClick={() => onChangedModifierSelect(m)}
>
{c.label ? intl.formatMessage({ id: c.label }) : ""}
{Criterion.getModifierLabel(intl, m)}
</Button>
))}
</Form.Group>

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { useFindPerformersQuery } from "src/core/generated-graphql";
import { ObjectsFilter } from "./SelectableFilter";
@@ -9,7 +9,7 @@ interface IPerformersFilter {
}
function usePerformerQuery(query: string) {
const results = useFindPerformersQuery({
const { data, loading } = useFindPerformersQuery({
variables: {
filter: {
q: query,
@@ -18,14 +18,18 @@ function usePerformerQuery(query: string) {
},
});
return (
results.data?.findPerformers.performers.map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? []
const results = useMemo(
() =>
data?.findPerformers.performers.map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? [],
[data]
);
return { results, loading };
}
const PerformersFilter: React.FC<IPerformersFilter> = ({
@@ -36,7 +40,7 @@ const PerformersFilter: React.FC<IPerformersFilter> = ({
<ObjectsFilter
criterion={criterion}
setCriterion={setCriterion}
queryHook={usePerformerQuery}
useResults={usePerformerQuery}
/>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { Icon } from "src/components/Shared/Icon";
import {
@@ -14,7 +14,7 @@ import {
ILabeledId,
ILabeledValueListValue,
} from "src/models/list-filter/types";
import { cloneDeep, debounce } from "lodash-es";
import { cloneDeep } from "lodash-es";
import {
Criterion,
IHierarchicalLabeledIdCriterion,
@@ -22,6 +22,8 @@ import {
import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
import { CriterionModifier } from "src/core/generated-graphql";
import { keyboardClickHandler } from "src/utils/keyboard";
import { useDebouncedSetState } from "src/hooks/debounce";
import useFocus from "src/utils/focus";
interface ISelectedItem {
item: ILabeledId;
@@ -77,40 +79,29 @@ const SelectedItem: React.FC<ISelectedItem> = ({
interface ISelectableFilter {
query: string;
setQuery: (query: string) => void;
single: boolean;
includeOnly: boolean;
onQueryChange: (query: string) => void;
modifier: CriterionModifier;
inputFocus: ReturnType<typeof useFocus>;
canExclude: boolean;
queryResults: ILabeledId[];
selected: ILabeledId[];
excluded: ILabeledId[];
onSelect: (value: ILabeledId, include: boolean) => void;
onSelect: (value: ILabeledId, exclude: boolean) => void;
onUnselect: (value: ILabeledId) => void;
}
const SelectableFilter: React.FC<ISelectableFilter> = ({
query,
setQuery,
single,
onQueryChange,
modifier,
inputFocus,
canExclude,
queryResults,
selected,
excluded,
includeOnly,
onSelect,
onUnselect,
}) => {
const [internalQuery, setInternalQuery] = useState(query);
const onInputChange = useMemo(() => {
return debounce((input: string) => {
setQuery(input);
}, 250);
}, [setQuery]);
function onInternalInputChange(input: string) {
setInternalQuery(input);
onInputChange(input);
}
const objects = useMemo(() => {
return queryResults.filter(
(p) =>
@@ -119,8 +110,10 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
);
}, [queryResults, selected, excluded]);
const includingOnly = includeOnly || (selected.length > 0 && single);
const excludingOnly = excluded.length > 0 && single;
const includingOnly = modifier == CriterionModifier.Equals;
const excludingOnly =
modifier == CriterionModifier.Excludes ||
modifier == CriterionModifier.NotEquals;
const includeIcon = <Icon className="fa-fw include-button" icon={faPlus} />;
const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />;
@@ -128,13 +121,18 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
return (
<div className="selectable-filter">
<ClearableInput
value={internalQuery}
setValue={(v) => onInternalInputChange(v)}
focus={inputFocus}
value={query}
setValue={(v) => onQueryChange(v)}
/>
<ul>
{selected.map((p) => (
<li key={p.id} className="selected-object">
<SelectedItem item={p} onClick={() => onUnselect(p)} />
<SelectedItem
item={p}
excluded={excludingOnly}
onClick={() => onUnselect(p)}
/>
</li>
))}
{excluded.map((p) => (
@@ -144,12 +142,9 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
))}
{objects.map((p) => (
<li key={p.id} className="unselected-object">
{/* if excluding only, clicking on an item also excludes it */}
<a
onClick={() => onSelect(p, !excludingOnly)}
onKeyDown={keyboardClickHandler(() =>
onSelect(p, !excludingOnly)
)}
onClick={() => onSelect(p, false)}
onKeyDown={keyboardClickHandler(() => onSelect(p, false))}
tabIndex={0}
>
<div>
@@ -159,11 +154,11 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
<div>
{/* TODO item count */}
{/* <span className="object-count">{p.id}</span> */}
{!includingOnly && !excludingOnly && (
{canExclude && !includingOnly && !excludingOnly && (
<Button
onClick={(e) => {
e.stopPropagation();
onSelect(p, false);
onSelect(p, true);
}}
onKeyDown={(e) => e.stopPropagation()}
className="minimal exclude-button"
@@ -183,36 +178,62 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> {
criterion: T;
single?: boolean;
setCriterion: (criterion: T) => void;
queryHook: (query: string) => ILabeledId[];
useResults: (query: string) => { results: ILabeledId[]; loading: boolean };
}
export const ObjectsFilter = <
T extends Criterion<ILabeledValueListValue | IHierarchicalLabelValue>
>(
props: IObjectsFilter<T>
) => {
const { criterion, setCriterion, queryHook, single = false } = props;
>({
criterion,
setCriterion,
useResults,
}: IObjectsFilter<T>) => {
const [query, setQuery] = useState("");
const [displayQuery, setDisplayQuery] = useState(query);
const queryResults = queryHook(query);
const debouncedSetQuery = useDebouncedSetState(setQuery, 250);
const onQueryChange = useCallback(
(input: string) => {
setDisplayQuery(input);
debouncedSetQuery(input);
},
[debouncedSetQuery, setDisplayQuery]
);
function onSelect(value: ILabeledId, newInclude: boolean) {
const [queryResults, setQueryResults] = useState<ILabeledId[]>([]);
const { results, loading: resultsLoading } = useResults(query);
useEffect(() => {
if (!resultsLoading) {
setQueryResults(results);
}
}, [results, resultsLoading]);
const inputFocus = useFocus();
const [, setInputFocus] = inputFocus;
function onSelect(value: ILabeledId, newExclude: boolean) {
let newCriterion: T = cloneDeep(criterion);
if (newInclude) {
newCriterion.value.items.push(value);
} else {
if (newExclude) {
if (newCriterion.value.excluded) {
newCriterion.value.excluded.push(value);
} else {
newCriterion.value.excluded = [value];
}
} else {
newCriterion.value.items.push(value);
}
setCriterion(newCriterion);
// reset filter query after selecting
debouncedSetQuery.cancel();
setQuery("");
setDisplayQuery("");
// focus the input box
setInputFocus();
}
const onUnselect = useCallback(
@@ -229,8 +250,11 @@ export const ObjectsFilter = <
);
setCriterion(newCriterion);
// focus the input box
setInputFocus();
},
[criterion, setCriterion]
[criterion, setCriterion, setInputFocus]
);
const sortedSelected = useMemo(() => {
@@ -246,12 +270,19 @@ export const ObjectsFilter = <
return ret;
}, [criterion]);
// if excludes is not a valid modifierOption then we can use `value.excluded`
const canExclude =
criterion.criterionOption.modifierOptions.find(
(m) => m === CriterionModifier.Excludes
) === undefined;
return (
<SelectableFilter
single={single}
includeOnly={criterion.modifier === CriterionModifier.Equals}
query={query}
setQuery={setQuery}
query={displayQuery}
onQueryChange={onQueryChange}
modifier={criterion.modifier}
inputFocus={inputFocus}
canExclude={canExclude}
selected={sortedSelected}
queryResults={queryResults}
onSelect={onSelect}

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { useFindStudiosQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
@@ -9,7 +9,7 @@ interface IStudiosFilter {
}
function useStudioQuery(query: string) {
const results = useFindStudiosQuery({
const { data, loading } = useFindStudiosQuery({
variables: {
filter: {
q: query,
@@ -18,14 +18,18 @@ function useStudioQuery(query: string) {
},
});
return (
results.data?.findStudios.studios.map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? []
const results = useMemo(
() =>
data?.findStudios.studios.map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? [],
[data]
);
return { results, loading };
}
const StudiosFilter: React.FC<IStudiosFilter> = ({
@@ -36,7 +40,7 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
<HierarchicalObjectsFilter
criterion={criterion}
setCriterion={setCriterion}
queryHook={useStudioQuery}
useResults={useStudioQuery}
/>
);
};

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { useFindTagsQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
@@ -8,8 +8,8 @@ interface ITagsFilter {
setCriterion: (c: StudiosCriterion) => void;
}
function useStudioQuery(query: string) {
const results = useFindTagsQuery({
function useTagQuery(query: string) {
const { data, loading } = useFindTagsQuery({
variables: {
filter: {
q: query,
@@ -18,14 +18,18 @@ function useStudioQuery(query: string) {
},
});
return (
results.data?.findTags.tags.map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? []
const results = useMemo(
() =>
data?.findTags.tags.map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? [],
[data]
);
return { results, loading };
}
const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
@@ -33,7 +37,7 @@ const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
<HierarchicalObjectsFilter
criterion={criterion}
setCriterion={setCriterion}
queryHook={useStudioQuery}
useResults={useTagQuery}
/>
);
};