diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 96278560bed..ffc707807ce 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -61,6 +61,27 @@ const GenericCriterionEditor: React.FC = ({ const { options, modifierOptions } = criterion.criterionOption; + const showModifierSelector = useMemo(() => { + if ( + criterion instanceof PerformersCriterion || + criterion instanceof StudiosCriterion || + criterion instanceof TagsCriterion + ) { + return false; + } + + return modifierOptions && modifierOptions.length > 1; + }, [criterion, modifierOptions]); + + const alwaysShowFilter = useMemo(() => { + return ( + criterion instanceof StashIDCriterion || + criterion instanceof PerformersCriterion || + criterion instanceof StudiosCriterion || + criterion instanceof TagsCriterion + ); + }, [criterion]); + const onChangedModifierSelect = useCallback( (m: CriterionModifier) => { const newCriterion = cloneDeep(criterion); @@ -71,7 +92,7 @@ const GenericCriterionEditor: React.FC = ({ ); const modifierSelector = useMemo(() => { - if (!modifierOptions || modifierOptions.length === 0) { + if (!showModifierSelector) { return; } @@ -90,7 +111,13 @@ const GenericCriterionEditor: React.FC = ({ ))} ); - }, [modifierOptions, onChangedModifierSelect, criterion.modifier, intl]); + }, [ + showModifierSelector, + modifierOptions, + onChangedModifierSelect, + criterion.modifier, + intl, + ]); const valueControl = useMemo(() => { function onValueChanged(value: CriterionValue) { @@ -108,8 +135,9 @@ const GenericCriterionEditor: React.FC = ({ // Hide the value select if the modifier is "IsNull" or "NotNull" if ( - criterion.modifier === CriterionModifier.IsNull || - criterion.modifier === CriterionModifier.NotNull + !alwaysShowFilter && + (criterion.modifier === CriterionModifier.IsNull || + criterion.modifier === CriterionModifier.NotNull) ) { return; } @@ -229,7 +257,7 @@ const GenericCriterionEditor: React.FC = ({ return ( ); - }, [criterion, setCriterion, options]); + }, [criterion, setCriterion, options, alwaysShowFilter]); return (
diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index e6f8f9fcf98..a53dc6effc5 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -24,19 +24,23 @@ import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; import useFocus from "src/utils/focus"; +import cx from "classnames"; import ScreenUtils from "src/utils/screen"; import { NumberField } from "src/utils/form"; interface ISelectedItem { - item: ILabeledId; + label: string; excluded?: boolean; onClick: () => void; + // true if the object is a special modifier value + modifier?: boolean; } const SelectedItem: React.FC = ({ - item, + label, excluded = false, onClick, + modifier = false, }) => { const iconClassName = excluded ? "exclude-icon" : "include-button"; const spanClassName = excluded @@ -61,21 +65,66 @@ const SelectedItem: React.FC = ({ } return ( - onClick()} - onKeyDown={keyboardClickHandler(onClick)} - onMouseEnter={() => onMouseOver()} - onMouseLeave={() => onMouseOut()} - onFocus={() => onMouseOver()} - onBlur={() => onMouseOut()} - tabIndex={0} - > -
- - {item.label} -
-
-
+
  • + onClick()} + onKeyDown={keyboardClickHandler(onClick)} + onMouseEnter={() => onMouseOver()} + onMouseLeave={() => onMouseOut()} + onFocus={() => onMouseOver()} + onBlur={() => onMouseOut()} + tabIndex={0} + > +
    + + {label} +
    +
    +
    +
  • + ); +}; + +const UnselectedItem: React.FC<{ + onSelect: (exclude: boolean) => void; + label: string; + canExclude: boolean; + // true if the object is a special modifier value + modifier?: boolean; +}> = ({ onSelect, label, canExclude, modifier = false }) => { + const includeIcon = ; + const excludeIcon = ; + + return ( +
  • + onSelect(false)} + onKeyDown={keyboardClickHandler(() => onSelect(false))} + tabIndex={0} + > +
    + {includeIcon} + {label} +
    +
    + {/* TODO item count */} + {/* {p.id} */} + {canExclude && ( + + )} +
    +
    +
  • ); }; @@ -83,6 +132,7 @@ interface ISelectableFilter { query: string; onQueryChange: (query: string) => void; modifier: CriterionModifier; + showModifierValues: boolean; inputFocus: ReturnType; canExclude: boolean; queryResults: ILabeledId[]; @@ -90,12 +140,31 @@ interface ISelectableFilter { excluded: ILabeledId[]; onSelect: (value: ILabeledId, exclude: boolean) => void; onUnselect: (value: ILabeledId) => void; + onSetModifier: (modifier: CriterionModifier) => void; + // true if the filter is for a single value + singleValue?: boolean; +} + +type SpecialValue = "any" | "none" | "any_of" | "only"; + +function modifierValueToModifier(key: SpecialValue): CriterionModifier { + switch (key) { + case "any": + return CriterionModifier.NotNull; + case "none": + return CriterionModifier.IsNull; + case "any_of": + return CriterionModifier.Includes; + case "only": + return CriterionModifier.Equals; + } } const SelectableFilter: React.FC = ({ query, onQueryChange, modifier, + showModifierValues, inputFocus, canExclude, queryResults, @@ -103,23 +172,73 @@ const SelectableFilter: React.FC = ({ excluded, onSelect, onUnselect, + onSetModifier, + singleValue, }) => { const intl = useIntl(); const objects = useMemo(() => { + if ( + modifier === CriterionModifier.IsNull || + modifier === CriterionModifier.NotNull + ) { + return []; + } return queryResults.filter( (p) => selected.find((s) => s.id === p.id) === undefined && excluded.find((s) => s.id === p.id) === undefined ); - }, [queryResults, selected, excluded]); + }, [modifier, queryResults, selected, excluded]); const includingOnly = modifier == CriterionModifier.Equals; const excludingOnly = modifier == CriterionModifier.Excludes || modifier == CriterionModifier.NotEquals; - const includeIcon = ; - const excludeIcon = ; + const modifierValues = useMemo(() => { + return { + any: modifier === CriterionModifier.NotNull, + none: modifier === CriterionModifier.IsNull, + any_of: !singleValue && modifier === CriterionModifier.Includes, + only: !singleValue && modifier === CriterionModifier.Equals, + }; + }, [modifier, singleValue]); + + const defaultModifier = useMemo(() => { + if (singleValue) { + return CriterionModifier.Includes; + } + return CriterionModifier.IncludesAll; + }, [singleValue]); + + const availableModifierValues: Record = useMemo(() => { + return { + any: + modifier === defaultModifier && + selected.length === 0 && + excluded.length === 0, + none: + modifier === defaultModifier && + selected.length === 0 && + excluded.length === 0, + any_of: + !singleValue && modifier === defaultModifier && selected.length > 1, + only: + !singleValue && + modifier === defaultModifier && + selected.length > 0 && + excluded.length === 0, + }; + }, [singleValue, defaultModifier, modifier, selected, excluded]); + + function onModifierValueSelect(key: SpecialValue) { + const m = modifierValueToModifier(key); + onSetModifier(m); + } + + function onModifierValueUnselect() { + onSetModifier(defaultModifier); + } return (
    @@ -130,50 +249,67 @@ const SelectableFilter: React.FC = ({ placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} />
    @@ -184,6 +320,7 @@ interface IObjectsFilter> { criterion: T; setCriterion: (criterion: T) => void; useResults: (query: string) => { results: ILabeledId[]; loading: boolean }; + singleValue?: boolean; } export const ObjectsFilter = < @@ -192,6 +329,7 @@ export const ObjectsFilter = < criterion, setCriterion, useResults, + singleValue, }: IObjectsFilter) => { const [query, setQuery] = useState(""); const [displayQuery, setDisplayQuery] = useState(query); @@ -264,6 +402,15 @@ export const ObjectsFilter = < [criterion, setCriterion, setInputFocus] ); + const onSetModifier = useCallback( + (modifier: CriterionModifier) => { + let newCriterion: T = criterion.clone(); + newCriterion.modifier = modifier; + setCriterion(newCriterion); + }, + [criterion, setCriterion] + ); + const sortedSelected = useMemo(() => { const ret = criterion.value.items.slice(); ret.sort((a, b) => a.label.localeCompare(b.label)); @@ -288,6 +435,7 @@ export const ObjectsFilter = < query={displayQuery} onQueryChange={onQueryChange} modifier={criterion.modifier} + showModifierValues={!query} inputFocus={inputFocus} canExclude={canExclude} selected={sortedSelected} @@ -295,6 +443,8 @@ export const ObjectsFilter = < onSelect={onSelect} onUnselect={onUnselect} excluded={sortedExcluded} + onSetModifier={onSetModifier} + singleValue={singleValue} /> ); }; @@ -347,18 +497,18 @@ export const HierarchicalObjectsFilter = < return (
    - {criterion.modifier !== CriterionModifier.Equals && ( - - - onDepthChanged(criterion.value.depth !== 0 ? 0 : -1) - } - /> - - )} + + onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} + disabled={criterion.modifier === CriterionModifier.Equals} + /> + {criterion.value.depth !== 0 && ( diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index a99fdde3a54..50765847476 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -45,6 +45,7 @@ const StudiosFilter: React.FC = ({ criterion={criterion} setCriterion={setCriterion} useResults={useStudioQuery} + singleValue /> ); }; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 9f9519b351f..f234e751126 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -303,6 +303,15 @@ input[type="range"].zoom-slider { padding-bottom: 0.15rem; padding-inline-start: 0; + .modifier-object { + font-style: italic; + + .selected-object-label, + .unselected-object-label { + opacity: 0.6; + } + } + .unselected-object { opacity: 0.8; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b90a77d2dd3..6be0a654240 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -844,6 +844,12 @@ "not_matches_regex": "not matches regex", "not_null": "is not null" }, + "criterion_modifier_values": { + "any": "Any", + "any_of": "Any of", + "none": "None", + "only": "Only" + }, "custom": "Custom", "date": "Date", "date_format": "YYYY-MM-DD",