Move modifiers into selectable options (#5203)

This commit is contained in:
WithoutPants 2024-10-29 14:17:46 +11:00 committed by GitHub
parent edb66bd4e4
commit f949fab231
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 269 additions and 75 deletions

View file

@ -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>

View file

@ -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>

View file

@ -45,6 +45,7 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
criterion={criterion}
setCriterion={setCriterion}
useResults={useStudioQuery}
singleValue
/>
);
};

View file

@ -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;
}

View file

@ -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",