mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Move modifiers into selectable options (#5203)
This commit is contained in:
parent
edb66bd4e4
commit
f949fab231
5 changed files with 269 additions and 75 deletions
|
|
@ -61,6 +61,27 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
|
|||
|
||||
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<IGenericCriterionEditor> = ({
|
|||
);
|
||||
|
||||
const modifierSelector = useMemo(() => {
|
||||
if (!modifierOptions || modifierOptions.length === 0) {
|
||||
if (!showModifierSelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +111,13 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
|
|||
))}
|
||||
</Form.Group>
|
||||
);
|
||||
}, [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<IGenericCriterionEditor> = ({
|
|||
|
||||
// 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<IGenericCriterionEditor> = ({
|
|||
return (
|
||||
<InputFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}, [criterion, setCriterion, options]);
|
||||
}, [criterion, setCriterion, options, alwaysShowFilter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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<ISelectedItem> = ({
|
||||
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<ISelectedItem> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
onClick={() => onClick()}
|
||||
onKeyDown={keyboardClickHandler(onClick)}
|
||||
onMouseEnter={() => onMouseOver()}
|
||||
onMouseLeave={() => onMouseOut()}
|
||||
onFocus={() => onMouseOver()}
|
||||
onBlur={() => onMouseOut()}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
<Icon className={`fa-fw ${iconClassName}`} icon={icon} />
|
||||
<span className={spanClassName}>{item.label}</span>
|
||||
</div>
|
||||
<div></div>
|
||||
</a>
|
||||
<li className={cx("selected-object", { "modifier-object": modifier })}>
|
||||
<a
|
||||
onClick={() => onClick()}
|
||||
onKeyDown={keyboardClickHandler(onClick)}
|
||||
onMouseEnter={() => onMouseOver()}
|
||||
onMouseLeave={() => onMouseOut()}
|
||||
onFocus={() => onMouseOver()}
|
||||
onBlur={() => onMouseOut()}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
<Icon className={`fa-fw ${iconClassName}`} icon={icon} />
|
||||
<span className={spanClassName}>{label}</span>
|
||||
</div>
|
||||
<div></div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
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 = <Icon className="fa-fw include-button" icon={faPlus} />;
|
||||
const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />;
|
||||
|
||||
return (
|
||||
<li className={cx("unselected-object", { "modifier-object": modifier })}>
|
||||
<a
|
||||
onClick={() => onSelect(false)}
|
||||
onKeyDown={keyboardClickHandler(() => onSelect(false))}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
{includeIcon}
|
||||
<span className="unselected-object-label">{label}</span>
|
||||
</div>
|
||||
<div>
|
||||
{/* TODO item count */}
|
||||
{/* <span className="object-count">{p.id}</span> */}
|
||||
{canExclude && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(true);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="minimal exclude-button"
|
||||
>
|
||||
<span className="exclude-button-text">exclude</span>
|
||||
{excludeIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -83,6 +132,7 @@ interface ISelectableFilter {
|
|||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
modifier: CriterionModifier;
|
||||
showModifierValues: boolean;
|
||||
inputFocus: ReturnType<typeof useFocus>;
|
||||
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<ISelectableFilter> = ({
|
||||
query,
|
||||
onQueryChange,
|
||||
modifier,
|
||||
showModifierValues,
|
||||
inputFocus,
|
||||
canExclude,
|
||||
queryResults,
|
||||
|
|
@ -103,23 +172,73 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
|
|||
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 = <Icon className="fa-fw include-button" icon={faPlus} />;
|
||||
const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />;
|
||||
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<SpecialValue, boolean> = 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 (
|
||||
<div className="selectable-filter">
|
||||
|
|
@ -130,50 +249,67 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
|
|||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||
/>
|
||||
<ul>
|
||||
{selected.map((p) => (
|
||||
<li key={p.id} className="selected-object">
|
||||
{Object.entries(modifierValues).map(([key, value]) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectedItem
|
||||
item={p}
|
||||
excluded={excludingOnly}
|
||||
key={key}
|
||||
onClick={() => onModifierValueUnselect()}
|
||||
label={`(${intl.formatMessage({
|
||||
id: `criterion_modifier_values.${key}`,
|
||||
})})`}
|
||||
modifier
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{selected.map((p) => (
|
||||
<SelectedItem
|
||||
key={p.id}
|
||||
label={p.label}
|
||||
excluded={excludingOnly}
|
||||
onClick={() => onUnselect(p)}
|
||||
/>
|
||||
))}
|
||||
{excluded.map((p) => (
|
||||
<li key={p.id} className="excluded-object">
|
||||
<SelectedItem
|
||||
label={p.label}
|
||||
excluded
|
||||
onClick={() => onUnselect(p)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{excluded.map((p) => (
|
||||
<li key={p.id} className="excluded-object">
|
||||
<SelectedItem item={p} excluded onClick={() => onUnselect(p)} />
|
||||
</li>
|
||||
))}
|
||||
{showModifierValues && (
|
||||
<>
|
||||
{Object.entries(availableModifierValues).map(([key, value]) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnselectedItem
|
||||
key={key}
|
||||
onSelect={() => onModifierValueSelect(key as SpecialValue)}
|
||||
label={`(${intl.formatMessage({
|
||||
id: `criterion_modifier_values.${key}`,
|
||||
})})`}
|
||||
canExclude={false}
|
||||
modifier
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{objects.map((p) => (
|
||||
<li key={p.id} className="unselected-object">
|
||||
<a
|
||||
onClick={() => onSelect(p, false)}
|
||||
onKeyDown={keyboardClickHandler(() => onSelect(p, false))}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
{!excludingOnly ? includeIcon : excludeIcon}
|
||||
<span>{p.label}</span>
|
||||
</div>
|
||||
<div>
|
||||
{/* TODO item count */}
|
||||
{/* <span className="object-count">{p.id}</span> */}
|
||||
{canExclude && !includingOnly && !excludingOnly && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(p, true);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="minimal exclude-button"
|
||||
>
|
||||
<span className="exclude-button-text">exclude</span>
|
||||
{excludeIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<UnselectedItem
|
||||
key={p.id}
|
||||
onSelect={(exclude) => onSelect(p, exclude)}
|
||||
label={p.label}
|
||||
canExclude={canExclude && !includingOnly && !excludingOnly}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -184,6 +320,7 @@ interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> {
|
|||
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<T>) => {
|
||||
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 (
|
||||
<Form>
|
||||
{criterion.modifier !== CriterionModifier.Equals && (
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id={criterionOptionTypeToIncludeID()}
|
||||
checked={criterion.value.depth !== 0}
|
||||
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
|
||||
onChange={() =>
|
||||
onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id={criterionOptionTypeToIncludeID()}
|
||||
checked={
|
||||
criterion.modifier !== CriterionModifier.Equals &&
|
||||
criterion.value.depth !== 0
|
||||
}
|
||||
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
|
||||
onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)}
|
||||
disabled={criterion.modifier === CriterionModifier.Equals}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
{criterion.value.depth !== 0 && (
|
||||
<Form.Group>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
|
|||
criterion={criterion}
|
||||
setCriterion={setCriterion}
|
||||
useResults={useStudioQuery}
|
||||
singleValue
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue