mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user