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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 344 additions and 216 deletions

View file

@ -77,17 +77,15 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
return ( return (
<Form.Group className="modifier-options"> <Form.Group className="modifier-options">
{modifierOptions.map((c) => ( {modifierOptions.map((m) => (
<Button <Button
className={cx("modifier-option", { className={cx("modifier-option", {
selected: criterion.modifier === c.value, selected: criterion.modifier === m,
})} })}
key={c.value} key={m}
onClick={() => onClick={() => onChangedModifierSelect(m)}
onChangedModifierSelect(c.value as CriterionModifier)
}
> >
{c.label ? intl.formatMessage({ id: c.label }) : ""} {Criterion.getModifierLabel(intl, m)}
</Button> </Button>
))} ))}
</Form.Group> </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 { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { useFindPerformersQuery } from "src/core/generated-graphql"; import { useFindPerformersQuery } from "src/core/generated-graphql";
import { ObjectsFilter } from "./SelectableFilter"; import { ObjectsFilter } from "./SelectableFilter";
@ -9,7 +9,7 @@ interface IPerformersFilter {
} }
function usePerformerQuery(query: string) { function usePerformerQuery(query: string) {
const results = useFindPerformersQuery({ const { data, loading } = useFindPerformersQuery({
variables: { variables: {
filter: { filter: {
q: query, q: query,
@ -18,14 +18,18 @@ function usePerformerQuery(query: string) {
}, },
}); });
return ( const results = useMemo(
results.data?.findPerformers.performers.map((p) => { () =>
return { data?.findPerformers.performers.map((p) => {
id: p.id, return {
label: p.name, id: p.id,
}; label: p.name,
}) ?? [] };
}) ?? [],
[data]
); );
return { results, loading };
} }
const PerformersFilter: React.FC<IPerformersFilter> = ({ const PerformersFilter: React.FC<IPerformersFilter> = ({
@ -36,7 +40,7 @@ const PerformersFilter: React.FC<IPerformersFilter> = ({
<ObjectsFilter <ObjectsFilter
criterion={criterion} criterion={criterion}
setCriterion={setCriterion} 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 { Button, Form } from "react-bootstrap";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { import {
@ -14,7 +14,7 @@ import {
ILabeledId, ILabeledId,
ILabeledValueListValue, ILabeledValueListValue,
} from "src/models/list-filter/types"; } from "src/models/list-filter/types";
import { cloneDeep, debounce } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { import {
Criterion, Criterion,
IHierarchicalLabeledIdCriterion, IHierarchicalLabeledIdCriterion,
@ -22,6 +22,8 @@ import {
import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { keyboardClickHandler } from "src/utils/keyboard"; import { keyboardClickHandler } from "src/utils/keyboard";
import { useDebouncedSetState } from "src/hooks/debounce";
import useFocus from "src/utils/focus";
interface ISelectedItem { interface ISelectedItem {
item: ILabeledId; item: ILabeledId;
@ -77,40 +79,29 @@ const SelectedItem: React.FC<ISelectedItem> = ({
interface ISelectableFilter { interface ISelectableFilter {
query: string; query: string;
setQuery: (query: string) => void; onQueryChange: (query: string) => void;
single: boolean; modifier: CriterionModifier;
includeOnly: boolean; inputFocus: ReturnType<typeof useFocus>;
canExclude: boolean;
queryResults: ILabeledId[]; queryResults: ILabeledId[];
selected: ILabeledId[]; selected: ILabeledId[];
excluded: ILabeledId[]; excluded: ILabeledId[];
onSelect: (value: ILabeledId, include: boolean) => void; onSelect: (value: ILabeledId, exclude: boolean) => void;
onUnselect: (value: ILabeledId) => void; onUnselect: (value: ILabeledId) => void;
} }
const SelectableFilter: React.FC<ISelectableFilter> = ({ const SelectableFilter: React.FC<ISelectableFilter> = ({
query, query,
setQuery, onQueryChange,
single, modifier,
inputFocus,
canExclude,
queryResults, queryResults,
selected, selected,
excluded, excluded,
includeOnly,
onSelect, onSelect,
onUnselect, 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(() => { const objects = useMemo(() => {
return queryResults.filter( return queryResults.filter(
(p) => (p) =>
@ -119,8 +110,10 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
); );
}, [queryResults, selected, excluded]); }, [queryResults, selected, excluded]);
const includingOnly = includeOnly || (selected.length > 0 && single); const includingOnly = modifier == CriterionModifier.Equals;
const excludingOnly = excluded.length > 0 && single; const excludingOnly =
modifier == CriterionModifier.Excludes ||
modifier == CriterionModifier.NotEquals;
const includeIcon = <Icon className="fa-fw include-button" icon={faPlus} />; const includeIcon = <Icon className="fa-fw include-button" icon={faPlus} />;
const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />; const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />;
@ -128,13 +121,18 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
return ( return (
<div className="selectable-filter"> <div className="selectable-filter">
<ClearableInput <ClearableInput
value={internalQuery} focus={inputFocus}
setValue={(v) => onInternalInputChange(v)} value={query}
setValue={(v) => onQueryChange(v)}
/> />
<ul> <ul>
{selected.map((p) => ( {selected.map((p) => (
<li key={p.id} className="selected-object"> <li key={p.id} className="selected-object">
<SelectedItem item={p} onClick={() => onUnselect(p)} /> <SelectedItem
item={p}
excluded={excludingOnly}
onClick={() => onUnselect(p)}
/>
</li> </li>
))} ))}
{excluded.map((p) => ( {excluded.map((p) => (
@ -144,12 +142,9 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
))} ))}
{objects.map((p) => ( {objects.map((p) => (
<li key={p.id} className="unselected-object"> <li key={p.id} className="unselected-object">
{/* if excluding only, clicking on an item also excludes it */}
<a <a
onClick={() => onSelect(p, !excludingOnly)} onClick={() => onSelect(p, false)}
onKeyDown={keyboardClickHandler(() => onKeyDown={keyboardClickHandler(() => onSelect(p, false))}
onSelect(p, !excludingOnly)
)}
tabIndex={0} tabIndex={0}
> >
<div> <div>
@ -159,11 +154,11 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
<div> <div>
{/* TODO item count */} {/* TODO item count */}
{/* <span className="object-count">{p.id}</span> */} {/* <span className="object-count">{p.id}</span> */}
{!includingOnly && !excludingOnly && ( {canExclude && !includingOnly && !excludingOnly && (
<Button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSelect(p, false); onSelect(p, true);
}} }}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
className="minimal exclude-button" className="minimal exclude-button"
@ -183,36 +178,62 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> { interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> {
criterion: T; criterion: T;
single?: boolean;
setCriterion: (criterion: T) => void; setCriterion: (criterion: T) => void;
queryHook: (query: string) => ILabeledId[]; useResults: (query: string) => { results: ILabeledId[]; loading: boolean };
} }
export const ObjectsFilter = < export const ObjectsFilter = <
T extends Criterion<ILabeledValueListValue | IHierarchicalLabelValue> T extends Criterion<ILabeledValueListValue | IHierarchicalLabelValue>
>( >({
props: IObjectsFilter<T> criterion,
) => { setCriterion,
const { criterion, setCriterion, queryHook, single = false } = props; useResults,
}: IObjectsFilter<T>) => {
const [query, setQuery] = useState(""); 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); let newCriterion: T = cloneDeep(criterion);
if (newInclude) { if (newExclude) {
newCriterion.value.items.push(value);
} else {
if (newCriterion.value.excluded) { if (newCriterion.value.excluded) {
newCriterion.value.excluded.push(value); newCriterion.value.excluded.push(value);
} else { } else {
newCriterion.value.excluded = [value]; newCriterion.value.excluded = [value];
} }
} else {
newCriterion.value.items.push(value);
} }
setCriterion(newCriterion); setCriterion(newCriterion);
// reset filter query after selecting
debouncedSetQuery.cancel();
setQuery("");
setDisplayQuery("");
// focus the input box
setInputFocus();
} }
const onUnselect = useCallback( const onUnselect = useCallback(
@ -229,8 +250,11 @@ export const ObjectsFilter = <
); );
setCriterion(newCriterion); setCriterion(newCriterion);
// focus the input box
setInputFocus();
}, },
[criterion, setCriterion] [criterion, setCriterion, setInputFocus]
); );
const sortedSelected = useMemo(() => { const sortedSelected = useMemo(() => {
@ -246,12 +270,19 @@ export const ObjectsFilter = <
return ret; return ret;
}, [criterion]); }, [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 ( return (
<SelectableFilter <SelectableFilter
single={single} query={displayQuery}
includeOnly={criterion.modifier === CriterionModifier.Equals} onQueryChange={onQueryChange}
query={query} modifier={criterion.modifier}
setQuery={setQuery} inputFocus={inputFocus}
canExclude={canExclude}
selected={sortedSelected} selected={sortedSelected}
queryResults={queryResults} queryResults={queryResults}
onSelect={onSelect} 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 { useFindStudiosQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
@ -9,7 +9,7 @@ interface IStudiosFilter {
} }
function useStudioQuery(query: string) { function useStudioQuery(query: string) {
const results = useFindStudiosQuery({ const { data, loading } = useFindStudiosQuery({
variables: { variables: {
filter: { filter: {
q: query, q: query,
@ -18,14 +18,18 @@ function useStudioQuery(query: string) {
}, },
}); });
return ( const results = useMemo(
results.data?.findStudios.studios.map((p) => { () =>
return { data?.findStudios.studios.map((p) => {
id: p.id, return {
label: p.name, id: p.id,
}; label: p.name,
}) ?? [] };
}) ?? [],
[data]
); );
return { results, loading };
} }
const StudiosFilter: React.FC<IStudiosFilter> = ({ const StudiosFilter: React.FC<IStudiosFilter> = ({
@ -36,7 +40,7 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
<HierarchicalObjectsFilter <HierarchicalObjectsFilter
criterion={criterion} criterion={criterion}
setCriterion={setCriterion} 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 { useFindTagsQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
@ -8,8 +8,8 @@ interface ITagsFilter {
setCriterion: (c: StudiosCriterion) => void; setCriterion: (c: StudiosCriterion) => void;
} }
function useStudioQuery(query: string) { function useTagQuery(query: string) {
const results = useFindTagsQuery({ const { data, loading } = useFindTagsQuery({
variables: { variables: {
filter: { filter: {
q: query, q: query,
@ -18,14 +18,18 @@ function useStudioQuery(query: string) {
}, },
}); });
return ( const results = useMemo(
results.data?.findTags.tags.map((p) => { () =>
return { data?.findTags.tags.map((p) => {
id: p.id, return {
label: p.name, id: p.id,
}; label: p.name,
}) ?? [] };
}) ?? [],
[data]
); );
return { results, loading };
} }
const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => { const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
@ -33,7 +37,7 @@ const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
<HierarchicalObjectsFilter <HierarchicalObjectsFilter
criterion={criterion} criterion={criterion}
setCriterion={setCriterion} setCriterion={setCriterion}
queryHook={useStudioQuery} useResults={useTagQuery}
/> />
); );
}; };

View file

@ -8,15 +8,17 @@ import useFocus from "src/utils/focus";
interface IClearableInput { interface IClearableInput {
value: string; value: string;
setValue: (value: string) => void; setValue: (value: string) => void;
focus: ReturnType<typeof useFocus>;
} }
export const ClearableInput: React.FC<IClearableInput> = ({ export const ClearableInput: React.FC<IClearableInput> = ({
value, value,
setValue, setValue,
focus,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const [queryRef, setQueryFocus] = useFocus(); const [queryRef, setQueryFocus] = focus;
const queryClearShowing = !!value; const queryClearShowing = !!value;
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) { function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {

View file

@ -730,7 +730,9 @@
"equals": "is", "equals": "is",
"excludes": "excludes", "excludes": "excludes",
"format_string": "{criterion} {modifierString} {valueString}", "format_string": "{criterion} {modifierString} {valueString}",
"format_string_depth": "{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})",
"format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})", "format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})",
"format_string_excludes_depth": "{criterion} {modifierString} {valueString} (excludes {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})",
"greater_than": "is greater than", "greater_than": "is greater than",
"includes": "includes", "includes": "includes",
"includes_all": "includes all", "includes_all": "includes all",

View file

@ -14,7 +14,7 @@ export class CountryCriterion extends StringCriterion {
super(countryCriterionOption); super(countryCriterionOption);
} }
public getLabelValue(intl: IntlShape) { protected getLabelValue(intl: IntlShape) {
if ( if (
this.modifier === CriterionModifier.Equals || this.modifier === CriterionModifier.Equals ||
this.modifier === CriterionModifier.NotEquals this.modifier === CriterionModifier.NotEquals

View file

@ -16,7 +16,6 @@ import {
CriterionType, CriterionType,
IHierarchicalLabelValue, IHierarchicalLabelValue,
ILabeledId, ILabeledId,
ILabeledValue,
INumberValue, INumberValue,
IOptionType, IOptionType,
IStashIDValue, IStashIDValue,
@ -39,6 +38,11 @@ export type CriterionValue =
| ITimestampValue | ITimestampValue
| IPhashDistanceValue; | IPhashDistanceValue;
export interface IEncodedCriterion<T extends CriterionValue> {
modifier: CriterionModifier;
value: T | undefined;
}
const modifierMessageIDs = { const modifierMessageIDs = {
[CriterionModifier.Equals]: "criterion_modifier.equals", [CriterionModifier.Equals]: "criterion_modifier.equals",
[CriterionModifier.NotEquals]: "criterion_modifier.not_equals", [CriterionModifier.NotEquals]: "criterion_modifier.not_equals",
@ -57,15 +61,15 @@ const modifierMessageIDs = {
// V = criterion value type // V = criterion value type
export abstract class Criterion<V extends CriterionValue> { export abstract class Criterion<V extends CriterionValue> {
public static getModifierOption(
modifier: CriterionModifier = CriterionModifier.Equals
): ILabeledValue {
const messageID = modifierMessageIDs[modifier];
return { value: modifier, label: messageID };
}
public criterionOption: CriterionOption; public criterionOption: CriterionOption;
public modifier: CriterionModifier;
protected _modifier!: CriterionModifier;
public get modifier(): CriterionModifier {
return this._modifier;
}
public set modifier(value: CriterionModifier) {
this._modifier = value;
}
protected _value!: V; protected _value!: V;
public get value(): V { public get value(): V {
@ -79,7 +83,7 @@ export abstract class Criterion<V extends CriterionValue> {
return true; return true;
} }
public abstract getLabelValue(intl: IntlShape): string; protected abstract getLabelValue(intl: IntlShape): string;
constructor(type: CriterionOption, value: V) { constructor(type: CriterionOption, value: V) {
this.criterionOption = type; this.criterionOption = type;
@ -140,8 +144,11 @@ export abstract class Criterion<V extends CriterionValue> {
return JSON.stringify(encodedCriterion); return JSON.stringify(encodedCriterion);
} }
public setValueFromQueryString(v: V) { public setFromEncodedCriterion(encodedCriterion: IEncodedCriterion<V>) {
this.value = v; if (encodedCriterion.value !== undefined) {
this.value = encodedCriterion.value;
}
this.modifier = encodedCriterion.modifier;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -174,7 +181,7 @@ export class CriterionOption {
public readonly messageID: string; public readonly messageID: string;
public readonly type: CriterionType; public readonly type: CriterionType;
public readonly parameterName: string; public readonly parameterName: string;
public readonly modifierOptions: ILabeledValue[]; public readonly modifierOptions: CriterionModifier[];
public readonly defaultModifier: CriterionModifier; public readonly defaultModifier: CriterionModifier;
public readonly options: Option[] | undefined; public readonly options: Option[] | undefined;
public readonly inputType: InputType; public readonly inputType: InputType;
@ -183,9 +190,7 @@ export class CriterionOption {
this.messageID = options.messageID; this.messageID = options.messageID;
this.type = options.type; this.type = options.type;
this.parameterName = options.parameterName ?? options.type; this.parameterName = options.parameterName ?? options.type;
this.modifierOptions = (options.modifierOptions ?? []).map((o) => this.modifierOptions = options.modifierOptions ?? [];
Criterion.getModifierOption(o)
);
this.defaultModifier = options.defaultModifier ?? CriterionModifier.Equals; this.defaultModifier = options.defaultModifier ?? CriterionModifier.Equals;
this.options = options.options; this.options = options.options;
this.inputType = options.inputType; this.inputType = options.inputType;
@ -237,7 +242,7 @@ export class StringCriterion extends Criterion<string> {
super(type, ""); super(type, "");
} }
public getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {
return this.value; return this.value;
} }
@ -255,7 +260,7 @@ export class MultiStringCriterion extends Criterion<string[]> {
super(type, []); super(type, []);
} }
public getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {
return this.value.join(", "); return this.value.join(", ");
} }
@ -437,7 +442,7 @@ export class NumberCriterion extends Criterion<INumberValue> {
}; };
} }
public getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {
const { value, value2 } = this.value; const { value, value2 } = this.value;
if ( if (
this.modifier === CriterionModifier.Between || this.modifier === CriterionModifier.Between ||
@ -509,7 +514,7 @@ export class ILabeledIdCriterionOption extends CriterionOption {
} }
export class ILabeledIdCriterion extends Criterion<ILabeledId[]> { export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
public getLabelValue(_intl: IntlShape): string { protected getLabelValue(_intl: IntlShape): string {
return this.value.map((v) => v.label).join(", "); return this.value.map((v) => v.label).join(", ");
} }
@ -547,15 +552,53 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
super(type, value); super(type, value);
} }
public setValueFromQueryString(v: IHierarchicalLabelValue) { override get modifier(): CriterionModifier {
this.value = { return this._modifier;
items: v.items || [], }
excluded: v.excluded || [], override set modifier(value: CriterionModifier) {
depth: v.depth || 0, this._modifier = value;
};
// excluded only makes sense for includes and includes all
// so reset it for other modifiers
if (
value !== CriterionModifier.Includes &&
value !== CriterionModifier.IncludesAll
) {
this.value.excluded = [];
}
} }
public getLabelValue(_intl: IntlShape): string { public setFromEncodedCriterion(
encodedCriterion: IEncodedCriterion<IHierarchicalLabelValue>
) {
const { modifier, value } = encodedCriterion;
if (value !== undefined) {
this.value = {
items: value.items || [],
excluded: value.excluded || [],
depth: value.depth || 0,
};
}
// if the previous modifier was excludes, replace it with the equivalent includes criterion
// this is what is done on the backend
// only replace if excludes is not a valid modifierOption
if (
modifier === CriterionModifier.Excludes &&
this.criterionOption.modifierOptions.find(
(m) => m === CriterionModifier.Excludes
) === undefined
) {
this.modifier = CriterionModifier.Includes;
this.value.excluded = [...this.value.excluded, ...this.value.items];
this.value.items = [];
} else {
this.modifier = modifier;
}
}
protected getLabelValue(_intl: IntlShape): string {
const labels = (this.value.items ?? []).map((v) => v.label).join(", "); const labels = (this.value.items ?? []).map((v) => v.label).join(", ");
if (this.value.depth === 0) { if (this.value.depth === 0) {
@ -586,8 +629,7 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
public isValid(): boolean { public isValid(): boolean {
if ( if (
this.modifier === CriterionModifier.IsNull || this.modifier === CriterionModifier.IsNull ||
this.modifier === CriterionModifier.NotNull || this.modifier === CriterionModifier.NotNull
this.modifier === CriterionModifier.Equals
) { ) {
return true; return true;
} }
@ -599,22 +641,33 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
} }
public getLabel(intl: IntlShape): string { public getLabel(intl: IntlShape): string {
const modifierString = Criterion.getModifierLabel(intl, this.modifier); let id = "criterion_modifier.format_string";
let modifierString = Criterion.getModifierLabel(intl, this.modifier);
let valueString = ""; let valueString = "";
let excludedString = "";
if ( if (
this.modifier !== CriterionModifier.IsNull && this.modifier !== CriterionModifier.IsNull &&
this.modifier !== CriterionModifier.NotNull this.modifier !== CriterionModifier.NotNull
) { ) {
valueString = this.value.items.map((v) => v.label).join(", "); valueString = this.value.items.map((v) => v.label).join(", ");
}
let id = "criterion_modifier.format_string"; if (this.value.excluded && this.value.excluded.length > 0) {
let excludedString = ""; if (this.value.items.length === 0) {
modifierString = Criterion.getModifierLabel(
intl,
CriterionModifier.Excludes
);
valueString = this.value.excluded.map((v) => v.label).join(", ");
} else {
id = "criterion_modifier.format_string_excludes";
excludedString = this.value.excluded.map((v) => v.label).join(", ");
}
}
if (this.value.excluded && this.value.excluded.length > 0) { if (this.value.depth !== 0) {
id = "criterion_modifier.format_string_excludes"; id += "_depth";
excludedString = this.value.excluded.map((v) => v.label).join(", "); }
} }
return intl.formatMessage( return intl.formatMessage(
@ -624,6 +677,7 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
modifierString, modifierString,
valueString, valueString,
excludedString, excludedString,
depth: this.value.depth,
} }
); );
} }
@ -669,7 +723,7 @@ export class DurationCriterion extends Criterion<INumberValue> {
}; };
} }
public getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {
return this.modifier === CriterionModifier.Between || return this.modifier === CriterionModifier.Between ||
this.modifier === CriterionModifier.NotBetween this.modifier === CriterionModifier.NotBetween
? `${DurationUtils.secondsToString( ? `${DurationUtils.secondsToString(
@ -764,7 +818,7 @@ export class DateCriterion extends Criterion<IDateValue> {
}; };
} }
public getLabelValue() { protected getLabelValue() {
const { value } = this.value; const { value } = this.value;
return this.modifier === CriterionModifier.Between || return this.modifier === CriterionModifier.Between ||
this.modifier === CriterionModifier.NotBetween this.modifier === CriterionModifier.NotBetween
@ -849,7 +903,7 @@ export class TimestampCriterion extends Criterion<ITimestampValue> {
}; };
} }
public getLabelValue() { protected getLabelValue() {
const { value } = this.value; const { value } = this.value;
return this.modifier === CriterionModifier.Between || return this.modifier === CriterionModifier.Between ||
this.modifier === CriterionModifier.NotBetween this.modifier === CriterionModifier.NotBetween

View file

@ -4,7 +4,6 @@ import { CriterionOption, StringCriterion, Option } from "./criterion";
export class IsMissingCriterion extends StringCriterion { export class IsMissingCriterion extends StringCriterion {
public modifierOptions = []; public modifierOptions = [];
public modifier = CriterionModifier.Equals;
protected toCriterionInput(): string { protected toCriterionInput(): string {
return this.value; return this.value;
@ -23,6 +22,7 @@ class IsMissingCriterionOptionClass extends CriterionOption {
type: value, type: value,
parameterName, parameterName,
options, options,
defaultModifier: CriterionModifier.Equals,
}); });
} }
} }

View file

@ -10,8 +10,7 @@ export class NoneCriterion extends Criterion<string> {
super(NoneCriterionOption, "none"); super(NoneCriterionOption, "none");
} }
// eslint-disable-next-line class-methods-use-this protected getLabelValue(): string {
public getLabelValue(): string {
return ""; return "";
} }
} }

View file

@ -5,12 +5,14 @@ import {
MultiCriterionInput, MultiCriterionInput,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { ILabeledId, ILabeledValueListValue } from "../types"; import { ILabeledId, ILabeledValueListValue } from "../types";
import { Criterion, CriterionOption } from "./criterion"; import { Criterion, CriterionOption, IEncodedCriterion } from "./criterion";
const modifierOptions = [ const modifierOptions = [
CriterionModifier.IncludesAll, CriterionModifier.IncludesAll,
CriterionModifier.Includes, CriterionModifier.Includes,
CriterionModifier.Equals, CriterionModifier.Equals,
CriterionModifier.IsNull,
CriterionModifier.NotNull,
]; ];
const defaultModifier = CriterionModifier.IncludesAll; const defaultModifier = CriterionModifier.IncludesAll;
@ -28,20 +30,50 @@ export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
super(PerformersCriterionOption, { items: [], excluded: [] }); super(PerformersCriterionOption, { items: [], excluded: [] });
} }
public setValueFromQueryString(v: ILabeledId[] | ILabeledValueListValue) { override get modifier(): CriterionModifier {
// #3619 - the format of performer value was changed from an array return this._modifier;
// to an object. Check for both formats. }
if (Array.isArray(v)) { override set modifier(value: CriterionModifier) {
this.value = { items: v, excluded: [] }; this._modifier = value;
} else {
this.value = { // excluded only makes sense for includes and includes all
items: v.items || [], // reset it for other modifiers
excluded: v.excluded || [], if (
}; value !== CriterionModifier.Includes &&
value !== CriterionModifier.IncludesAll
) {
this.value.excluded = [];
} }
} }
public getLabelValue(_intl: IntlShape): string { public setFromEncodedCriterion(
encodedCriterion: IEncodedCriterion<ILabeledId[] | ILabeledValueListValue>
) {
const { modifier, value } = encodedCriterion;
// #3619 - the format of performer value was changed from an array
// to an object. Check for both formats.
if (Array.isArray(value)) {
this.value = { items: value, excluded: [] };
} else if (value !== undefined) {
this.value = {
items: value.items || [],
excluded: value.excluded || [],
};
}
// if the previous modifier was excludes, replace it with the equivalent includes criterion
// this is what is done on the backend
if (modifier === CriterionModifier.Excludes) {
this.modifier = CriterionModifier.Includes;
this.value.excluded = [...this.value.excluded, ...this.value.items];
this.value.items = [];
} else {
this.modifier = modifier;
}
}
protected getLabelValue(_intl: IntlShape): string {
return this.value.items.map((v) => v.label).join(", "); return this.value.items.map((v) => v.label).join(", ");
} }
@ -60,8 +92,7 @@ export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
public isValid(): boolean { public isValid(): boolean {
if ( if (
this.modifier === CriterionModifier.IsNull || this.modifier === CriterionModifier.IsNull ||
this.modifier === CriterionModifier.NotNull || this.modifier === CriterionModifier.NotNull
this.modifier === CriterionModifier.Equals
) { ) {
return true; return true;
} }
@ -73,22 +104,29 @@ export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
} }
public getLabel(intl: IntlShape): string { public getLabel(intl: IntlShape): string {
const modifierString = Criterion.getModifierLabel(intl, this.modifier); let id = "criterion_modifier.format_string";
let modifierString = Criterion.getModifierLabel(intl, this.modifier);
let valueString = ""; let valueString = "";
let excludedString = "";
if ( if (
this.modifier !== CriterionModifier.IsNull && this.modifier !== CriterionModifier.IsNull &&
this.modifier !== CriterionModifier.NotNull this.modifier !== CriterionModifier.NotNull
) { ) {
valueString = this.value.items.map((v) => v.label).join(", "); valueString = this.value.items.map((v) => v.label).join(", ");
}
let id = "criterion_modifier.format_string"; if (this.value.excluded && this.value.excluded.length > 0) {
let excludedString = ""; if (this.value.items.length === 0) {
modifierString = Criterion.getModifierLabel(
if (this.value.excluded && this.value.excluded.length > 0) { intl,
id = "criterion_modifier.format_string_excludes"; CriterionModifier.Excludes
excludedString = this.value.excluded.map((v) => v.label).join(", "); );
valueString = this.value.excluded.map((v) => v.label).join(", ");
} else {
id = "criterion_modifier.format_string_excludes";
excludedString = this.value.excluded.map((v) => v.label).join(", ");
}
}
} }
return intl.formatMessage( return intl.formatMessage(

View file

@ -28,7 +28,7 @@ export class PhashCriterion extends Criterion<IPhashDistanceValue> {
super(PhashCriterionOption, { value: "", distance: 0 }); super(PhashCriterionOption, { value: "", distance: 0 });
} }
public getLabelValue() { protected getLabelValue() {
const { value, distance } = this.value; const { value, distance } = this.value;
if ( if (
(this.modifier === CriterionModifier.Equals || (this.modifier === CriterionModifier.Equals ||

View file

@ -36,7 +36,7 @@ export class RatingCriterion extends Criterion<INumberValue> {
}; };
} }
public getLabelValue() { protected getLabelValue() {
const { value, value2 } = this.value; const { value, value2 } = this.value;
if ( if (
this.modifier === CriterionModifier.Between || this.modifier === CriterionModifier.Between ||

View file

@ -74,7 +74,7 @@ export class StashIDCriterion extends Criterion<IStashIDValue> {
); );
} }
public getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {
let ret = this.value.stashID; let ret = this.value.stashID;
if (this.value.endpoint) { if (this.value.endpoint) {
ret += " (" + this.value.endpoint + ")"; ret += " (" + this.value.endpoint + ")";

View file

@ -6,7 +6,11 @@ import {
ILabeledIdCriterionOption, ILabeledIdCriterionOption,
} from "./criterion"; } from "./criterion";
const modifierOptions = [CriterionModifier.Includes]; const modifierOptions = [
CriterionModifier.Includes,
CriterionModifier.IsNull,
CriterionModifier.NotNull,
];
const defaultModifier = CriterionModifier.Includes; const defaultModifier = CriterionModifier.Includes;

View file

@ -1,66 +1,57 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { CriterionType } from "../types";
import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion"; import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion";
export class TagsCriterion extends IHierarchicalLabeledIdCriterion {} const modifierOptions = [
const tagsModifierOptions = [
CriterionModifier.Includes,
CriterionModifier.IncludesAll, CriterionModifier.IncludesAll,
CriterionModifier.Includes,
CriterionModifier.Equals, CriterionModifier.Equals,
CriterionModifier.IsNull,
CriterionModifier.NotNull,
]; ];
const withoutEqualsModifierOptions = [ const withoutEqualsModifierOptions = [
CriterionModifier.Includes,
CriterionModifier.IncludesAll, CriterionModifier.IncludesAll,
CriterionModifier.Includes,
CriterionModifier.IsNull,
CriterionModifier.NotNull,
]; ];
class tagsCriterionOption extends CriterionOption { const defaultModifier = CriterionModifier.IncludesAll;
constructor(
messageID: string,
value: CriterionType,
parameterName: string,
modifierOptions: CriterionModifier[]
) {
let defaultModifier = CriterionModifier.IncludesAll;
super({ export const TagsCriterionOption = new CriterionOption({
messageID, messageID: "tags",
type: value, type: "tags",
parameterName, parameterName: "tags",
modifierOptions, modifierOptions,
defaultModifier, defaultModifier,
}); });
} export const SceneTagsCriterionOption = new CriterionOption({
} messageID: "sceneTags",
type: "sceneTags",
parameterName: "scene_tags",
modifierOptions,
defaultModifier,
});
export const PerformerTagsCriterionOption = new CriterionOption({
messageID: "performerTags",
type: "performerTags",
parameterName: "performer_tags",
modifierOptions: withoutEqualsModifierOptions,
defaultModifier,
});
export const ParentTagsCriterionOption = new CriterionOption({
messageID: "parent_tags",
type: "parentTags",
parameterName: "parents",
modifierOptions: withoutEqualsModifierOptions,
defaultModifier,
});
export const ChildTagsCriterionOption = new CriterionOption({
messageID: "sub_tags",
type: "childTags",
parameterName: "children",
modifierOptions: withoutEqualsModifierOptions,
defaultModifier,
});
export const TagsCriterionOption = new tagsCriterionOption( export class TagsCriterion extends IHierarchicalLabeledIdCriterion {}
"tags",
"tags",
"tags",
tagsModifierOptions
);
export const SceneTagsCriterionOption = new tagsCriterionOption(
"sceneTags",
"sceneTags",
"scene_tags",
tagsModifierOptions
);
export const PerformerTagsCriterionOption = new tagsCriterionOption(
"performerTags",
"performerTags",
"performer_tags",
withoutEqualsModifierOptions
);
export const ParentTagsCriterionOption = new tagsCriterionOption(
"parent_tags",
"parentTags",
"parents",
withoutEqualsModifierOptions
);
export const ChildTagsCriterionOption = new tagsCriterionOption(
"sub_tags",
"childTags",
"children",
withoutEqualsModifierOptions
);

View file

@ -130,10 +130,7 @@ export class ListFilterModel {
const criterion = makeCriteria(this.config, encodedCriterion.type); const criterion = makeCriteria(this.config, encodedCriterion.type);
// it's possible that we have unsupported criteria. Just skip if so. // it's possible that we have unsupported criteria. Just skip if so.
if (criterion) { if (criterion) {
if (encodedCriterion.value !== undefined) { criterion.setFromEncodedCriterion(encodedCriterion);
criterion.setValueFromQueryString(encodedCriterion.value);
}
criterion.modifier = encodedCriterion.modifier;
this.criteria.push(criterion); this.criteria.push(criterion);
} }
} catch (err) { } catch (err) {