Custom fields filter UI (#5632)

* Refactor criteria and criterion options
* Add custom fields filtering in UI
This commit is contained in:
WithoutPants
2025-02-24 14:32:53 +11:00
committed by GitHub
parent 46d424fbaf
commit b6ace42973
44 changed files with 929 additions and 292 deletions

View File

@@ -29,7 +29,7 @@ import { sortByRelevance } from "src/utils/query";
import { galleryTitle } from "src/core/galleries";
import { PatchComponent, PatchFunction } from "src/patch";
import {
Criterion,
ModifierCriterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { PathCriterion } from "src/models/list-filter/criteria/path";
@@ -46,7 +46,7 @@ type Option = SelectOption<Gallery>;
type ExtraGalleryProps = {
hoverPlacement?: Placement;
excludeIds?: string[];
extraCriteria?: Array<Criterion<CriterionValue>>;
extraCriteria?: Array<ModifierCriterion<CriterionValue>>;
};
type FindGalleriesResult = Awaited<

View File

@@ -1,19 +1,18 @@
import cloneDeep from "lodash-es/cloneDeep";
import React, { useCallback, useMemo } from "react";
import { Button, Form } from "react-bootstrap";
import { CriterionModifier } from "src/core/generated-graphql";
import {
DurationCriterion,
CriterionValue,
Criterion,
ModifierCriterion,
IHierarchicalLabeledIdCriterion,
NumberCriterion,
ILabeledIdCriterion,
DateCriterion,
TimestampCriterion,
BooleanCriterion,
Criterion,
} from "src/models/list-filter/criteria/criterion";
import { useIntl } from "react-intl";
import {
criterionIsHierarchicalLabelValue,
criterionIsNumberValue,
@@ -45,21 +44,21 @@ import { TagsCriterion } from "src/models/list-filter/criteria/tags";
import TagsFilter from "./Filters/TagsFilter";
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
import { PhashFilter } from "./Filters/PhashFilter";
import cx from "classnames";
import { PathCriterion } from "src/models/list-filter/criteria/path";
import { ModifierSelectorButtons } from "./ModifierSelect";
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
import { CustomFieldsFilter } from "./Filters/CustomFieldsFilter";
interface IGenericCriterionEditor {
criterion: Criterion<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
criterion: ModifierCriterion<CriterionValue>;
setCriterion: (c: ModifierCriterion<CriterionValue>) => void;
}
const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
criterion,
setCriterion,
}) => {
const intl = useIntl();
const { options, modifierOptions } = criterion.criterionOption;
const { options, modifierOptions } = criterion.modifierCriterionOption();
const showModifierSelector = useMemo(() => {
if (
@@ -97,26 +96,17 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
}
return (
<Form.Group className="modifier-options">
{modifierOptions.map((m) => (
<Button
className={cx("modifier-option", {
selected: criterion.modifier === m,
})}
key={m}
onClick={() => onChangedModifierSelect(m)}
>
{Criterion.getModifierLabel(intl, m)}
</Button>
))}
</Form.Group>
<ModifierSelectorButtons
options={modifierOptions}
value={criterion.modifier}
onChanged={onChangedModifierSelect}
/>
);
}, [
showModifierSelector,
modifierOptions,
onChangedModifierSelect,
criterion.modifier,
intl,
]);
const valueControl = useMemo(() => {
@@ -268,8 +258,8 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
};
interface ICriterionEditor {
criterion: Criterion<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
criterion: Criterion;
setCriterion: (c: Criterion) => void;
}
export const CriterionEditor: React.FC<ICriterionEditor> = ({
@@ -283,12 +273,22 @@ export const CriterionEditor: React.FC<ICriterionEditor> = ({
);
}
return (
<GenericCriterionEditor
criterion={criterion}
setCriterion={setCriterion}
/>
);
if (criterion instanceof CustomFieldsCriterion) {
return (
<CustomFieldsFilter criterion={criterion} setCriterion={setCriterion} />
);
}
if (criterion instanceof ModifierCriterion) {
return (
<GenericCriterionEditor
criterion={criterion}
setCriterion={setCriterion}
/>
);
}
return null;
}, [criterion, setCriterion]);
return <div className="criterion-editor">{filterControl}</div>;

View File

@@ -10,7 +10,6 @@ import React, {
import { Accordion, Button, Card, Form, Modal } from "react-bootstrap";
import cx from "classnames";
import {
CriterionValue,
Criterion,
CriterionOption,
} from "src/models/list-filter/criteria/criterion";
@@ -38,8 +37,8 @@ import ScreenUtils from "src/utils/screen";
interface ICriterionList {
criteria: string[];
currentCriterion?: Criterion<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
currentCriterion?: Criterion;
setCriterion: (c: Criterion) => void;
criterionOptions: CriterionOption[];
pinnedCriterionOptions: CriterionOption[];
selected?: CriterionOption;
@@ -228,7 +227,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
const [currentFilter, setCurrentFilter] = useState<ListFilterModel>(
cloneDeep(filter)
);
const [criterion, setCriterion] = useState<Criterion<CriterionValue>>();
const [criterion, setCriterion] = useState<Criterion>();
const [searchRef, setSearchFocus] = useFocusOnce(!ScreenUtils.isTouch());
@@ -364,7 +363,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
}
}
function replaceCriterion(c: Criterion<CriterionValue>) {
function replaceCriterion(c: Criterion) {
const newFilter = cloneDeep(currentFilter);
if (!c.isValid()) {
@@ -397,18 +396,26 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
setCriterion(c);
}
function removeCriterion(c: Criterion<CriterionValue>) {
const newFilter = cloneDeep(currentFilter);
function removeCriterion(c: Criterion, valueIndex?: number) {
if (valueIndex !== undefined) {
setCurrentFilter(
currentFilter.removeCustomFieldCriterion(
c.criterionOption.type,
valueIndex
)
);
} else {
const newFilter = cloneDeep(currentFilter);
const newCriteria = criteria.filter((cc) => {
return cc.getId() !== c.getId();
});
const newCriteria = criteria.filter((cc) => {
return cc.getId() !== c.getId();
});
newFilter.criteria = newCriteria;
newFilter.criteria = newCriteria;
setCurrentFilter(newFilter);
if (criterion?.getId() === c.getId()) {
optionSelected(undefined);
setCurrentFilter(newFilter);
if (criterion?.getId() === c.getId()) {
optionSelected(undefined);
}
}
}
@@ -462,7 +469,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
<FilterTags
criteria={criteria}
onEditCriterion={(c) => optionSelected(c.criterionOption)}
onRemoveCriterion={(c) => removeCriterion(c)}
onRemoveCriterion={removeCriterion}
onRemoveAll={() => onClearAll()}
/>
</div>

View File

@@ -1,17 +1,50 @@
import React from "react";
import { Badge, Button } from "react-bootstrap";
import {
Criterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import React, { PropsWithChildren } from "react";
import { Badge, BadgeProps, Button } from "react-bootstrap";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "../Shared/Icon";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
type TagItemProps = PropsWithChildren<
ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps>
>;
export const TagItem: React.FC<TagItemProps> = (props) => {
const { children } = props;
return (
<Badge className="tag-item" variant="secondary" {...props}>
{children}
</Badge>
);
};
export const FilterTag: React.FC<{
label: React.ReactNode;
onClick: React.MouseEventHandler<HTMLSpanElement>;
onRemove: React.MouseEventHandler<HTMLElement>;
}> = ({ label, onClick, onRemove }) => {
return (
<TagItem onClick={onClick}>
{label}
<Button
variant="secondary"
onClick={(e) => {
onRemove(e);
e.stopPropagation();
}}
>
<Icon icon={faTimes} />
</Button>
</TagItem>
);
};
interface IFilterTagsProps {
criteria: Criterion<CriterionValue>[];
onEditCriterion: (c: Criterion<CriterionValue>) => void;
onRemoveCriterion: (c: Criterion<CriterionValue>) => void;
criteria: Criterion[];
onEditCriterion: (c: Criterion) => void;
onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;
onRemoveAll: () => void;
}
@@ -24,59 +57,62 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
const intl = useIntl();
function onRemoveCriterionTag(
criterion: Criterion<CriterionValue>,
$event: React.MouseEvent<HTMLElement, MouseEvent>
criterion: Criterion,
$event: React.MouseEvent<HTMLElement, MouseEvent>,
valueIndex?: number
) {
if (!criterion) {
return;
}
onRemoveCriterion(criterion);
onRemoveCriterion(criterion, valueIndex);
$event.stopPropagation();
}
function onClickCriterionTag(criterion: Criterion<CriterionValue>) {
function onClickCriterionTag(criterion: Criterion) {
onEditCriterion(criterion);
}
function renderFilterTags() {
return criteria.map((criterion) => (
<Badge
className="tag-item"
variant="secondary"
key={criterion.getId()}
onClick={() => onClickCriterionTag(criterion)}
>
{criterion.getLabel(intl)}
<Button
variant="secondary"
onClick={($event) => onRemoveCriterionTag(criterion, $event)}
>
<Icon icon={faTimes} />
</Button>
</Badge>
));
}
function maybeRenderClearAll() {
if (criteria.length < 3) {
return;
function renderFilterTags(criterion: Criterion) {
if (
criterion instanceof CustomFieldsCriterion &&
criterion.value.length > 1
) {
return criterion.value.map((value, index) => {
return (
<FilterTag
key={index}
label={criterion.getValueLabel(intl, value)}
onClick={() => onClickCriterionTag(criterion)}
onRemove={($event) =>
onRemoveCriterionTag(criterion, $event, index)
}
/>
);
});
}
return (
<Button
variant="minimal"
className="clear-all-button"
onClick={() => onRemoveAll()}
>
<FormattedMessage id="actions.clear" />
</Button>
<FilterTag
key={criterion.getId()}
label={criterion.getLabel(intl)}
onClick={() => onClickCriterionTag(criterion)}
onRemove={($event) => onRemoveCriterionTag(criterion, $event)}
/>
);
}
return (
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
{renderFilterTags()}
{maybeRenderClearAll()}
{criteria.map(renderFilterTags)}
{criteria.length >= 3 && (
<Button
variant="minimal"
className="clear-all-button"
onClick={() => onRemoveAll()}
>
<FormattedMessage id="actions.clear" />
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,312 @@
import React, { useEffect, useMemo, useState } from "react";
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
import { Button, Col, Form, Row } from "react-bootstrap";
import {
CriterionModifier,
CustomFieldCriterionInput,
} from "src/core/generated-graphql";
import { cloneDeep } from "@apollo/client/utilities";
import { ModifierSelect } from "../ModifierSelect";
import { useIntl } from "react-intl";
import { Icon } from "src/components/Shared/Icon";
import { faCheck, faPencil, faTimes } from "@fortawesome/free-solid-svg-icons";
import { FilterTag } from "../FilterTags";
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
interface ICustomFieldCriterionEditor {
criterion?: CustomFieldCriterionInput;
setCriterion: (c: CustomFieldCriterionInput) => void;
cancel: () => void;
editing?: boolean;
}
function getValue(v: string) {
// if the value is numeric, convert it to a number
const num = Number(v);
if (!isNaN(num)) {
return num;
} else {
return v;
}
}
const CustomFieldCriterionEditor: React.FC<ICustomFieldCriterionEditor> = ({
criterion,
setCriterion,
editing = false,
cancel,
}) => {
const intl = useIntl();
const [field, setField] = React.useState(criterion?.field ?? "");
const [value, setValue] = React.useState(criterion?.value);
const [modifier, setModifier] = React.useState(
criterion?.modifier ?? CriterionModifier.Equals
);
const firstValue = value && value.length > 0 ? (value[0] as string) : "";
const secondValue = value && value.length > 1 ? (value[1] as string) : "";
useEffect(() => {
setField((criterion?.field as string) ?? "");
setValue(criterion?.value ?? []);
setModifier(criterion?.modifier ?? CriterionModifier.Equals);
}, [criterion]);
function setFirstValue(v: string) {
// convert to numeric if possible
const nv = getValue(v);
if (
modifier === CriterionModifier.Between ||
modifier === CriterionModifier.NotBetween
) {
setValue([nv, secondValue]);
} else {
setValue([nv]);
}
}
function setSecondValue(v: string) {
setValue([firstValue, getValue(v)]);
}
function onChangeModifier(m: CriterionModifier) {
setModifier(m);
if (m === CriterionModifier.IsNull || m === CriterionModifier.NotNull) {
setValue(undefined);
}
}
function onConfirm() {
setCriterion({
field,
value,
modifier,
});
}
const firstPlaceholder =
modifier === CriterionModifier.Between ||
modifier === CriterionModifier.NotBetween
? intl.formatMessage({ id: "criterion.greater_than" })
: intl.formatMessage({ id: "custom_fields.value" });
const hasTwoValues =
modifier === CriterionModifier.Between ||
modifier === CriterionModifier.NotBetween;
return (
<Form.Group className="custom-field-filter">
<div>
<Row noGutters>
<Col xs={6}>
<Form.Control
className="btn-secondary"
type="text"
placeholder={intl.formatMessage({ id: "custom_fields.field" })}
onChange={(e) => setField(e.target.value)}
value={field}
/>
</Col>
<Col xs={6}>
<ModifierSelect
value={modifier}
onChanged={(m) => onChangeModifier(m)}
/>
</Col>
</Row>
<Row noGutters>
{modifier !== CriterionModifier.IsNull &&
modifier !== CriterionModifier.NotNull && (
<Col xs={hasTwoValues ? 6 : 12}>
<Form.Control
placeholder={firstPlaceholder}
className="btn-secondary"
type="text"
onChange={(e) => setFirstValue(e.target.value)}
value={firstValue}
/>
</Col>
)}
{(modifier === CriterionModifier.Between ||
modifier === CriterionModifier.NotBetween) && (
<Col xs={6}>
<Form.Control
placeholder={intl.formatMessage({ id: "criterion.less_than" })}
className="btn-secondary"
type="text"
onChange={(e) => setSecondValue(e.target.value)}
value={secondValue}
/>
</Col>
)}
</Row>
</div>
<div className="custom-field-filter-buttons">
<Button variant="success" onClick={() => onConfirm()} disabled={!field}>
<Icon icon={faCheck} />
</Button>
{editing && (
<Button variant="secondary" onClick={() => cancel()}>
<Icon icon={faTimes} />
</Button>
)}
</div>
</Form.Group>
);
};
function valueToString(value: unknown[] | undefined | null) {
if (!value) return "";
return value.map((v) => v as string).join(", ");
}
const CustomFieldFilterTag: React.FC<{
criterion: CustomFieldCriterionInput;
editing?: boolean;
onEditCriterion: () => void;
onRemoveCriterion: () => void;
}> = ({ criterion, editing, onEditCriterion, onRemoveCriterion }) => {
const intl = useIntl();
const label = useMemo(() => {
const { field, modifier, value } = criterion;
const modifierString = ModifierCriterion.getModifierLabel(intl, modifier);
const str = intl.formatMessage(
{ id: "criterion_modifier.format_string" },
{
criterion: field,
modifierString,
valueString: valueToString(value),
}
);
if (editing) {
return (
<span>
<Icon icon={faPencil} />
{str}
</span>
);
}
return <>{str}</>;
}, [criterion, editing, intl]);
return (
<FilterTag
label={label}
onClick={onEditCriterion}
onRemove={onRemoveCriterion}
/>
);
};
const CustomFieldsCriteriaPills: React.FC<{
criteria: CustomFieldCriterionInput[];
editIndex?: number;
onEditCriterion: (index: number) => void;
onRemoveCriterion: (index: number) => void;
}> = ({ criteria, editIndex, onEditCriterion, onRemoveCriterion }) => {
return (
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
{criteria.map((c, index) => (
<CustomFieldFilterTag
key={index}
editing={index === editIndex}
criterion={c}
onEditCriterion={() => onEditCriterion(index)}
onRemoveCriterion={() => onRemoveCriterion(index)}
/>
))}
</div>
);
};
interface ICustomFieldsFilter {
criterion: CustomFieldsCriterion;
setCriterion: (c: CustomFieldsCriterion) => void;
}
function initCriterion(
criterion: CustomFieldsCriterion
): CustomFieldsCriterion {
return cloneDeep(criterion);
}
function createNewCriterion(): CustomFieldCriterionInput {
return {
field: "",
value: [],
modifier: CriterionModifier.Equals,
};
}
export const CustomFieldsFilter: React.FC<ICustomFieldsFilter> = ({
criterion,
setCriterion,
}) => {
const [localCriterion, setLocalCriterion] = React.useState(
initCriterion(criterion)
);
const [editCriterion, setEditCriterion] = useState(createNewCriterion());
const editIndex = useMemo(
() => localCriterion.value.indexOf(editCriterion),
[localCriterion, editCriterion]
);
function updateCriteria(newCriteria: CustomFieldCriterionInput[]) {
// update the parent - filter out invalid criteria
const validCriteria = newCriteria.filter((c) => c.field !== "");
const newValue = cloneDeep(criterion);
newValue.value = validCriteria;
setCriterion(newValue);
}
function onChange(nv: CustomFieldCriterionInput) {
const newValue = cloneDeep(localCriterion);
// if the criterion is new, add it to the list
if (editIndex === -1) {
newValue.value.push(nv);
} else {
newValue.value[editIndex] = nv;
}
setLocalCriterion(newValue);
updateCriteria(newValue.value);
setEditCriterion(createNewCriterion());
}
function onRemove(index: number) {
const c = cloneDeep(localCriterion);
c.value.splice(index, 1);
setLocalCriterion(c);
updateCriteria(c.value);
if (index === editIndex) {
setEditCriterion(createNewCriterion());
}
}
return (
<Form.Group>
<CustomFieldCriterionEditor
criterion={editCriterion}
editing={editCriterion.field !== ""}
setCriterion={onChange}
cancel={() => setEditCriterion(createNewCriterion())}
/>
<CustomFieldsCriteriaPills
criteria={localCriterion.value}
editIndex={editIndex !== -1 ? editIndex : undefined}
onEditCriterion={(index) =>
setEditCriterion(localCriterion.value[index])
}
onRemoveCriterion={(index) => onRemove(index)}
/>
</Form.Group>
);
};

View File

@@ -3,11 +3,11 @@ import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql";
import { IDateValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion";
import { DateInput } from "src/components/Shared/DateInput";
interface IDateFilterProps {
criterion: Criterion<IDateValue>;
criterion: ModifierCriterion<IDateValue>;
onValueChanged: (value: IDateValue) => void;
}

View File

@@ -4,10 +4,10 @@ import { useIntl } from "react-intl";
import { CriterionModifier } from "src/core/generated-graphql";
import { DurationInput } from "src/components/Shared/DurationInput";
import { INumberValue } from "src/models/list-filter/types";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
interface IDurationFilterProps {
criterion: Criterion<INumberValue>;
criterion: ModifierCriterion<INumberValue>;
onValueChanged: (value: INumberValue) => void;
}

View File

@@ -2,19 +2,19 @@ import React from "react";
import { Form } from "react-bootstrap";
import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
import { IHierarchicalLabelValue } from "src/models/list-filter/types";
import { NumberField } from "src/utils/form";
interface IHierarchicalLabelValueFilterProps {
criterion: Criterion<IHierarchicalLabelValue>;
criterion: ModifierCriterion<IHierarchicalLabelValue>;
onValueChanged: (value: IHierarchicalLabelValue) => void;
}
export const HierarchicalLabelValueFilter: React.FC<
IHierarchicalLabelValueFilterProps
> = ({ criterion, onValueChanged }) => {
const { criterionOption } = criterion;
const criterionOption = criterion.modifierCriterionOption();
const { type, inputType } = criterionOption;
const intl = useIntl();

View File

@@ -1,12 +1,12 @@
import React from "react";
import { Form } from "react-bootstrap";
import {
Criterion,
ModifierCriterion,
CriterionValue,
} from "../../../models/list-filter/criteria/criterion";
interface IInputFilterProps {
criterion: Criterion<CriterionValue>;
criterion: ModifierCriterion<CriterionValue>;
onValueChanged: (value: string) => void;
}
@@ -23,7 +23,7 @@ export const InputFilter: React.FC<IInputFilterProps> = ({
<Form.Group>
<Form.Control
className="btn-secondary"
type={criterion.criterionOption.inputType}
type={criterion.modifierCriterionOption().inputType}
onChange={onChanged}
value={criterion.value ? criterion.value.toString() : ""}
/>

View File

@@ -3,11 +3,11 @@ import { Form } from "react-bootstrap";
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
import { ILabeledId } from "src/models/list-filter/types";
interface ILabeledIdFilterProps {
criterion: Criterion<ILabeledId[]>;
criterion: ModifierCriterion<ILabeledId[]>;
onValueChanged: (value: ILabeledId[]) => void;
}
@@ -15,7 +15,7 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
criterion,
onValueChanged,
}) => {
const { criterionOption } = criterion;
const criterionOption = criterion.modifierCriterionOption();
const { inputType } = criterionOption;
if (

View File

@@ -3,12 +3,12 @@ import React from "react";
import { Form } from "react-bootstrap";
import {
CriterionValue,
Criterion,
ModifierCriterion,
} from "src/models/list-filter/criteria/criterion";
interface IOptionsFilter {
criterion: Criterion<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
criterion: ModifierCriterion<CriterionValue>;
setCriterion: (c: ModifierCriterion<CriterionValue>) => void;
}
export const OptionFilter: React.FC<IOptionsFilter> = ({
@@ -26,7 +26,7 @@ export const OptionFilter: React.FC<IOptionsFilter> = ({
setCriterion(c);
}
const { options } = criterion.criterionOption;
const { options } = criterion.modifierCriterionOption();
return (
<div className="option-list-filter">
@@ -45,8 +45,8 @@ export const OptionFilter: React.FC<IOptionsFilter> = ({
};
interface IOptionsListFilter {
criterion: Criterion<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
criterion: ModifierCriterion<CriterionValue>;
setCriterion: (c: ModifierCriterion<CriterionValue>) => void;
}
export const OptionListFilter: React.FC<IOptionsListFilter> = ({
@@ -65,7 +65,7 @@ export const OptionListFilter: React.FC<IOptionsListFilter> = ({
setCriterion(c);
}
const { options } = criterion.criterionOption;
const { options } = criterion.modifierCriterionOption();
const value = criterion.value as string[];
return (

View File

@@ -4,12 +4,12 @@ import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
import { CriterionModifier } from "src/core/generated-graphql";
import { ConfigurationContext } from "src/hooks/Config";
import {
Criterion,
ModifierCriterion,
CriterionValue,
} from "../../../models/list-filter/criteria/criterion";
interface IInputFilterProps {
criterion: Criterion<CriterionValue>;
criterion: ModifierCriterion<CriterionValue>;
onValueChanged: (value: string) => void;
}
@@ -30,7 +30,7 @@ export const PathFilter: React.FC<IInputFilterProps> = ({
{regex ? (
<Form.Control
className="btn-secondary"
type={criterion.criterionOption.inputType}
type={criterion.modifierCriterionOption().inputType}
onChange={(v) => onValueChanged(v.target.value)}
value={criterion.value ? criterion.value.toString() : ""}
/>

View File

@@ -2,12 +2,12 @@ import React from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { IPhashDistanceValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion";
import { CriterionModifier } from "src/core/generated-graphql";
import { NumberField } from "src/utils/form";
interface IPhashFilterProps {
criterion: Criterion<IPhashDistanceValue>;
criterion: ModifierCriterion<IPhashDistanceValue>;
onValueChanged: (value: IPhashDistanceValue) => void;
}

View File

@@ -2,11 +2,11 @@ import React from "react";
import { FormattedMessage } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql";
import { INumberValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
interface IRatingFilterProps {
criterion: Criterion<INumberValue>;
criterion: ModifierCriterion<INumberValue>;
onValueChanged: (value: INumberValue) => void;
}

View File

@@ -16,7 +16,7 @@ import {
} from "src/models/list-filter/types";
import { cloneDeep } from "lodash-es";
import {
Criterion,
ModifierCriterion,
IHierarchicalLabeledIdCriterion,
} from "src/models/list-filter/criteria/criterion";
import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
@@ -316,7 +316,7 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
);
};
interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> {
interface IObjectsFilter<T extends ModifierCriterion<ILabeledValueListValue>> {
criterion: T;
setCriterion: (criterion: T) => void;
useResults: (query: string) => { results: ILabeledId[]; loading: boolean };
@@ -324,7 +324,7 @@ interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> {
}
export const ObjectsFilter = <
T extends Criterion<ILabeledValueListValue | IHierarchicalLabelValue>
T extends ModifierCriterion<ILabeledValueListValue | IHierarchicalLabelValue>
>({
criterion,
setCriterion,
@@ -426,9 +426,10 @@ export const ObjectsFilter = <
// if excludes is not a valid modifierOption then we can use `value.excluded`
const canExclude =
criterion.criterionOption.modifierOptions.find(
(m) => m === CriterionModifier.Excludes
) === undefined;
criterion
.modifierCriterionOption()
.modifierOptions.find((m) => m === CriterionModifier.Excludes) ===
undefined;
return (
<SelectableFilter

View File

@@ -2,11 +2,11 @@ import React from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { IStashIDValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion";
import { CriterionModifier } from "src/core/generated-graphql";
interface IStashIDFilterProps {
criterion: Criterion<IStashIDValue>;
criterion: ModifierCriterion<IStashIDValue>;
onValueChanged: (value: IStashIDValue) => void;
}

View File

@@ -3,11 +3,11 @@ import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql";
import { ITimestampValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion";
import { DateInput } from "src/components/Shared/DateInput";
interface ITimestampFilterProps {
criterion: Criterion<ITimestampValue>;
criterion: ModifierCriterion<ITimestampValue>;
onValueChanged: (value: ITimestampValue) => void;
}

View File

@@ -8,10 +8,7 @@ import React, {
} from "react";
import * as GQL from "src/core/generated-graphql";
import { QueryResult } from "@apollo/client";
import {
Criterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { EditFilterDialog } from "src/components/List/EditFilterDialog";
import { FilterTags } from "./FilterTags";
@@ -220,8 +217,19 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
result.refetch();
}
function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
updateFilter(filter.removeCriterion(removedCriterion.criterionOption.type));
function onRemoveCriterion(removedCriterion: Criterion, valueIndex?: number) {
if (valueIndex === undefined) {
updateFilter(
filter.removeCriterion(removedCriterion.criterionOption.type)
);
} else {
updateFilter(
filter.removeCustomFieldCriterion(
removedCriterion.criterionOption.type,
valueIndex
)
);
}
}
function onClearAllCriteria() {

View File

@@ -0,0 +1,72 @@
import React from "react";
import { Button, Form } from "react-bootstrap";
import { CriterionModifier } from "src/core/generated-graphql";
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
import cx from "classnames";
import { useIntl } from "react-intl";
const defaultOptions = [
CriterionModifier.IsNull,
CriterionModifier.NotNull,
CriterionModifier.Equals,
CriterionModifier.NotEquals,
CriterionModifier.Includes,
CriterionModifier.Excludes,
CriterionModifier.GreaterThan,
CriterionModifier.LessThan,
CriterionModifier.Between,
CriterionModifier.NotBetween,
];
interface IModifierSelect {
options?: CriterionModifier[];
value: CriterionModifier;
onChanged: (m: CriterionModifier) => void;
}
export const ModifierSelectorButtons: React.FC<IModifierSelect> = ({
options = defaultOptions,
value,
onChanged,
}) => {
const intl = useIntl();
return (
<Form.Group className="modifier-options">
{options.map((m) => (
<Button
className={cx("modifier-option", {
selected: value === m,
})}
key={m}
onClick={() => onChanged(m)}
>
{ModifierCriterion.getModifierLabel(intl, m)}
</Button>
))}
</Form.Group>
);
};
export const ModifierSelect: React.FC<IModifierSelect> = ({
options = defaultOptions,
value,
onChanged,
}) => {
const intl = useIntl();
return (
<Form.Control
as="select"
onChange={(e) => onChanged(e.target.value as CriterionModifier)}
value={value}
className="btn-secondary modifier-selector"
>
{options.map((m) => (
<option key={m} value={m}>
{ModifierCriterion.getModifierLabel(intl, m)}
</option>
))}
</Form.Control>
);
};

View File

@@ -67,7 +67,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
mode: filter.mode,
name,
find_filter: filterCopy.makeFindFilter(),
object_filter: filterCopy.makeSavedFilter(),
object_filter: filterCopy.makeFilter(),
ui_options: filterCopy.makeSavedUIOptions(),
},
},
@@ -142,7 +142,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
value: {
mode: filter.mode,
find_filter: filterCopy.makeFindFilter(),
object_filter: filterCopy.makeSavedFilter(),
object_filter: filterCopy.makeFilter(),
ui_options: filterCopy.makeSavedUIOptions(),
},
},

View File

@@ -587,3 +587,29 @@ input[type="range"].zoom-slider {
.search-term-input {
margin-right: 0.5rem;
}
.custom-field-filter {
align-items: center;
display: flex;
> div:first-child {
flex-grow: 1;
}
.custom-field-filter-buttons {
display: flex;
flex-direction: column;
margin-left: 0.25rem;
.btn {
border-radius: 0.2rem;
font-size: 0.875rem;
line-height: 1.5;
padding: 0.25rem 0.5rem;
&:first-child {
margin-bottom: 0.25rem;
}
}
}
}

View File

@@ -12,7 +12,7 @@ import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink";
import { Button, ButtonGroup } from "react-bootstrap";
import {
Criterion,
ModifierCriterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
@@ -25,10 +25,10 @@ import ScreenUtils from "src/utils/screen";
import { FavoriteIcon } from "../Shared/FavoriteIcon";
export interface IPerformerCardExtraCriteria {
scenes?: Criterion<CriterionValue>[];
images?: Criterion<CriterionValue>[];
galleries?: Criterion<CriterionValue>[];
groups?: Criterion<CriterionValue>[];
scenes?: ModifierCriterion<CriterionValue>[];
images?: ModifierCriterion<CriterionValue>[];
galleries?: ModifierCriterion<CriterionValue>[];
groups?: ModifierCriterion<CriterionValue>[];
performer?: ILabeledId;
}

View File

@@ -29,7 +29,7 @@ import { sortByRelevance } from "src/utils/query";
import { objectTitle } from "src/core/files";
import { PatchComponent, PatchFunction } from "src/patch";
import {
Criterion,
ModifierCriterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { TruncatedText } from "../Shared/TruncatedText";
@@ -45,7 +45,7 @@ type Option = SelectOption<Scene>;
type ExtraSceneProps = {
hoverPlacement?: Placement;
excludeIds?: string[];
extraCriteria?: Array<Criterion<CriterionValue>>;
extraCriteria?: Array<ModifierCriterion<CriterionValue>>;
};
type FindScenesResult = Awaited<

View File

@@ -856,6 +856,8 @@
},
"custom": "Custom",
"custom_fields": {
"criteria_format_string": "{criterion} (custom field) {modifierString} {valueString}",
"criteria_format_string_others": "{criterion} (custom field) {modifierString} {valueString} (+{others} others)",
"field": "Field",
"title": "Custom Fields",
"value": "Value"

View File

@@ -1,10 +1,10 @@
import { CriterionModifier } from "src/core/generated-graphql";
import { languageMap, valueToCode } from "src/utils/caption";
import { CriterionOption, StringCriterion } from "./criterion";
import { ModifierCriterionOption, StringCriterion } from "./criterion";
const languageStrings = Array.from(languageMap.values());
export const CaptionsCriterionOption = new CriterionOption({
export const CaptionsCriterionOption = new ModifierCriterionOption({
messageID: "captions",
type: "captions",
modifierOptions: [

View File

@@ -4,9 +4,9 @@ import {
CriterionModifier,
} from "src/core/generated-graphql";
import { circumcisedStrings, stringToCircumcised } from "src/utils/circumcised";
import { CriterionOption, MultiStringCriterion } from "./criterion";
import { ModifierCriterionOption, MultiStringCriterion } from "./criterion";
export const CircumcisedCriterionOption = new CriterionOption({
export const CircumcisedCriterionOption = new ModifierCriterionOption({
messageID: "circumcised",
type: "circumcised",
modifierOptions: [

View File

@@ -57,10 +57,41 @@ const modifierMessageIDs = {
[CriterionModifier.NotBetween]: "criterion_modifier.not_between",
};
// V = criterion value type
export abstract class Criterion<V extends CriterionValue> {
export abstract class Criterion {
public criterionOption: CriterionOption;
constructor(type: CriterionOption) {
this.criterionOption = type;
}
public isValid(): boolean {
return true;
}
public clone() {
const ret = Object.assign(Object.create(Object.getPrototypeOf(this)), this);
ret.cloneValues();
return ret;
}
protected cloneValues() {}
public abstract getLabel(intl: IntlShape): string;
public getId(): string {
return `${this.criterionOption.type}`;
}
public abstract toJSON(): string;
public abstract applyToCriterionInput(input: Record<string, unknown>): void;
public abstract setFromSavedCriterion(criterion: unknown): void;
}
// V = criterion value type
export abstract class ModifierCriterion<
V extends CriterionValue
> extends Criterion {
protected _modifier!: CriterionModifier;
public get modifier(): CriterionModifier {
return this._modifier;
@@ -83,12 +114,16 @@ export abstract class Criterion<V extends CriterionValue> {
protected abstract getLabelValue(intl: IntlShape): string;
constructor(type: CriterionOption, value: V) {
this.criterionOption = type;
constructor(type: ModifierCriterionOption, value: V) {
super(type);
this.modifier = type.defaultModifier;
this.value = value;
}
public modifierCriterionOption() {
return this.criterionOption as ModifierCriterionOption;
}
public clone() {
const ret = Object.assign(Object.create(Object.getPrototypeOf(this)), this);
ret.cloneValues();
@@ -106,7 +141,10 @@ export abstract class Criterion<V extends CriterionValue> {
}
public getLabel(intl: IntlShape): string {
const modifierString = Criterion.getModifierLabel(intl, this.modifier);
const modifierString = ModifierCriterion.getModifierLabel(
intl,
this.modifier
);
let valueString = "";
if (
@@ -126,10 +164,6 @@ export abstract class Criterion<V extends CriterionValue> {
);
}
public getId(): string {
return `${this.criterionOption.type}-${this.modifier.toString()}`; // TODO add values?
}
public toJSON() {
let encodedCriterion;
if (
@@ -157,14 +191,11 @@ export abstract class Criterion<V extends CriterionValue> {
this.modifier = criterion.modifier;
}
public toCriterionInput(): unknown {
return {
value: this.value,
modifier: this.modifier,
};
public applyToCriterionInput(input: Record<string, unknown>) {
input[this.criterionOption.type] = this.toCriterionInput();
}
public toSavedCriterion(): ISavedCriterion<V> {
public toCriterionInput(): unknown {
return {
value: this.value,
modifier: this.modifier,
@@ -185,44 +216,31 @@ export type InputType =
| "galleries"
| undefined;
interface ICriterionOptionsParams {
type MakeCriterionFn = (
o: CriterionOption,
config?: ConfigDataFragment
) => Criterion;
interface ICriterionOptionParams {
messageID: string;
type: CriterionType;
inputType?: InputType;
modifierOptions?: CriterionModifier[];
defaultModifier?: CriterionModifier;
options?: Option[];
makeCriterion: MakeCriterionFn;
hidden?: boolean;
makeCriterion: (
o: CriterionOption,
config?: ConfigDataFragment
) => Criterion<CriterionValue>;
}
export class CriterionOption {
public readonly messageID: string;
public readonly type: CriterionType;
public readonly modifierOptions: CriterionModifier[];
public readonly defaultModifier: CriterionModifier;
public readonly options: Option[] | undefined;
public readonly inputType: InputType;
public readonly messageID: string;
public readonly makeCriterionFn: MakeCriterionFn;
// used for legacy criteria that are not shown in the UI
public readonly hidden: boolean = false;
public readonly makeCriterionFn: (
o: CriterionOption,
config?: ConfigDataFragment
) => Criterion<CriterionValue>;
constructor(options: ICriterionOptionsParams) {
this.messageID = options.messageID;
constructor(options: ICriterionOptionParams) {
this.type = options.type;
this.modifierOptions = options.modifierOptions ?? [];
this.defaultModifier = options.defaultModifier ?? CriterionModifier.Equals;
this.options = options.options;
this.inputType = options.inputType;
this.hidden = options.hidden ?? false;
this.messageID = options.messageID;
this.makeCriterionFn = options.makeCriterion;
this.hidden = options.hidden ?? false;
}
public makeCriterion(config?: ConfigDataFragment) {
@@ -230,13 +248,35 @@ export class CriterionOption {
}
}
export class ILabeledIdCriterionOption extends CriterionOption {
interface IModifierCriterionOptionParams extends ICriterionOptionParams {
inputType?: InputType;
modifierOptions?: CriterionModifier[];
defaultModifier?: CriterionModifier;
options?: Option[];
}
export class ModifierCriterionOption extends CriterionOption {
public readonly modifierOptions: CriterionModifier[];
public readonly defaultModifier: CriterionModifier;
public readonly options: Option[] | undefined;
public readonly inputType: InputType;
constructor(options: IModifierCriterionOptionParams) {
super(options);
this.modifierOptions = options.modifierOptions ?? [];
this.defaultModifier = options.defaultModifier ?? CriterionModifier.Equals;
this.options = options.options;
this.inputType = options.inputType;
}
}
export class ILabeledIdCriterionOption extends ModifierCriterionOption {
constructor(
messageID: string,
value: CriterionType,
includeAll: boolean,
inputType: InputType,
makeCriterion?: () => Criterion<CriterionValue>
makeCriterion?: () => ModifierCriterion<CriterionValue>
) {
const modifierOptions = [
CriterionModifier.Includes,
@@ -264,8 +304,8 @@ export class ILabeledIdCriterionOption extends CriterionOption {
}
}
export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
constructor(type: CriterionOption, value: ILabeledId[] = []) {
export class ILabeledIdCriterion extends ModifierCriterion<ILabeledId[]> {
constructor(type: ModifierCriterionOption, value: ILabeledId[] = []) {
super(type, value);
}
@@ -296,9 +336,9 @@ export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
}
}
export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
export class IHierarchicalLabeledIdCriterion extends ModifierCriterion<IHierarchicalLabelValue> {
constructor(
type: CriterionOption,
type: ModifierCriterionOption,
value: IHierarchicalLabelValue = {
items: [],
excluded: [],
@@ -346,14 +386,16 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
};
}
const modifierOptions =
(this.criterionOption as ModifierCriterionOption).modifierOptions ?? [];
// 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
modifierOptions.find((m) => m === CriterionModifier.Excludes) ===
undefined
) {
this.modifier = CriterionModifier.Includes;
this.value.excluded = [...this.value.excluded, ...this.value.items];
@@ -407,7 +449,10 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
public getLabel(intl: IntlShape): string {
let id = "criterion_modifier.format_string";
let modifierString = Criterion.getModifierLabel(intl, this.modifier);
let modifierString = ModifierCriterion.getModifierLabel(
intl,
this.modifier
);
let valueString = "";
let excludedString = "";
@@ -419,7 +464,7 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
if (this.value.excluded && this.value.excluded.length > 0) {
if (this.value.items.length === 0) {
modifierString = Criterion.getModifierLabel(
modifierString = ModifierCriterion.getModifierLabel(
intl,
CriterionModifier.Excludes
);
@@ -448,11 +493,11 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
}
}
export class StringCriterionOption extends CriterionOption {
export class StringCriterionOption extends ModifierCriterionOption {
constructor(
messageID: string,
value: CriterionType,
makeCriterion?: () => Criterion<CriterionValue>
makeCriterion?: () => ModifierCriterion<CriterionValue>
) {
super({
messageID,
@@ -483,7 +528,7 @@ export function createStringCriterionOption(
return new StringCriterionOption(messageID ?? type, type);
}
export class MandatoryStringCriterionOption extends CriterionOption {
export class MandatoryStringCriterionOption extends ModifierCriterionOption {
constructor(messageID: string, value: CriterionType) {
super({
messageID,
@@ -510,8 +555,8 @@ export function createMandatoryStringCriterionOption(
return new MandatoryStringCriterionOption(messageID ?? value, value);
}
export class StringCriterion extends Criterion<string> {
constructor(type: CriterionOption) {
export class StringCriterion extends ModifierCriterion<string> {
constructor(type: ModifierCriterionOption) {
super(type, "");
}
@@ -528,8 +573,8 @@ export class StringCriterion extends Criterion<string> {
}
}
export abstract class MultiStringCriterion extends Criterion<string[]> {
constructor(type: CriterionOption, value: string[] = []) {
export abstract class MultiStringCriterion extends ModifierCriterion<string[]> {
constructor(type: ModifierCriterionOption, value: string[] = []) {
super(type, value);
}
@@ -550,11 +595,11 @@ export abstract class MultiStringCriterion extends Criterion<string[]> {
}
}
export class BooleanCriterionOption extends CriterionOption {
export class BooleanCriterionOption extends ModifierCriterionOption {
constructor(
messageID: string,
value: CriterionType,
makeCriterion?: () => Criterion<CriterionValue>
makeCriterion?: () => ModifierCriterion<CriterionValue>
) {
super({
messageID,
@@ -586,11 +631,11 @@ export class BooleanCriterion extends StringCriterion {
}
}
export class StringBooleanCriterionOption extends CriterionOption {
export class StringBooleanCriterionOption extends ModifierCriterionOption {
constructor(
messageID: string,
value: CriterionType,
makeCriterion?: () => Criterion<CriterionValue>
makeCriterion?: () => ModifierCriterion<CriterionValue>
) {
super({
messageID,
@@ -613,7 +658,7 @@ export class StringBooleanCriterion extends StringCriterion {
}
}
export class NumberCriterionOption extends CriterionOption {
export class NumberCriterionOption extends ModifierCriterionOption {
constructor(messageID: string, value: CriterionType) {
super({
messageID,
@@ -642,11 +687,11 @@ export function createNumberCriterionOption(
return new NumberCriterionOption(messageID ?? value, value);
}
export class NullNumberCriterionOption extends CriterionOption {
export class NullNumberCriterionOption extends ModifierCriterionOption {
constructor(
messageID: string,
value: CriterionType,
makeCriterion?: () => Criterion<CriterionValue>
makeCriterion?: MakeCriterionFn
) {
super({
messageID,
@@ -677,11 +722,11 @@ export function createNullNumberCriterionOption(
return new NullNumberCriterionOption(messageID ?? value, value);
}
export class MandatoryNumberCriterionOption extends CriterionOption {
export class MandatoryNumberCriterionOption extends ModifierCriterionOption {
constructor(
messageID: string,
value: CriterionType,
makeCriterion?: () => Criterion<CriterionValue>
makeCriterion?: () => ModifierCriterion<CriterionValue>
) {
super({
messageID,
@@ -710,8 +755,8 @@ export function createMandatoryNumberCriterionOption(
return new MandatoryNumberCriterionOption(messageID ?? value, value);
}
export class NumberCriterion extends Criterion<INumberValue> {
constructor(type: CriterionOption) {
export class NumberCriterion extends ModifierCriterion<INumberValue> {
constructor(type: ModifierCriterionOption) {
super(type, { value: undefined, value2: undefined });
}
@@ -805,8 +850,8 @@ export function createNullDurationCriterionOption(
return new NullDurationCriterionOption(messageID ?? value, value);
}
export class DurationCriterion extends Criterion<INumberValue> {
constructor(type: CriterionOption) {
export class DurationCriterion extends ModifierCriterion<INumberValue> {
constructor(type: ModifierCriterionOption) {
super(type, { value: undefined, value2: undefined });
}
@@ -860,7 +905,7 @@ export class DurationCriterion extends Criterion<INumberValue> {
}
}
export class DateCriterionOption extends CriterionOption {
export class DateCriterionOption extends ModifierCriterionOption {
constructor(messageID: string, value: CriterionType) {
super({
messageID,
@@ -886,8 +931,8 @@ export function createDateCriterionOption(value: CriterionType) {
return new DateCriterionOption(value, value);
}
export class DateCriterion extends Criterion<IDateValue> {
constructor(type: CriterionOption) {
export class DateCriterion extends ModifierCriterion<IDateValue> {
constructor(type: ModifierCriterionOption) {
super(type, { value: "", value2: undefined });
}
@@ -943,7 +988,7 @@ export class DateCriterion extends Criterion<IDateValue> {
}
}
export class TimestampCriterionOption extends CriterionOption {
export class TimestampCriterionOption extends ModifierCriterionOption {
constructor(messageID: string, value: CriterionType) {
super({
messageID,
@@ -967,7 +1012,7 @@ export function createTimestampCriterionOption(value: CriterionType) {
return new TimestampCriterionOption(value, value);
}
export class MandatoryTimestampCriterionOption extends CriterionOption {
export class MandatoryTimestampCriterionOption extends ModifierCriterionOption {
constructor(messageID: string, value: CriterionType) {
super({
messageID,
@@ -989,8 +1034,8 @@ export function createMandatoryTimestampCriterionOption(value: CriterionType) {
return new MandatoryTimestampCriterionOption(value, value);
}
export class TimestampCriterion extends Criterion<ITimestampValue> {
constructor(type: CriterionOption) {
export class TimestampCriterion extends ModifierCriterion<ITimestampValue> {
constructor(type: ModifierCriterionOption) {
super(type, { value: "", value2: undefined });
}

View File

@@ -0,0 +1,109 @@
import { IntlShape } from "react-intl";
import { Criterion, CriterionOption, ModifierCriterion } from "./criterion";
import {
CriterionModifier,
CustomFieldCriterionInput,
} from "src/core/generated-graphql";
import { cloneDeep } from "@apollo/client/utilities";
export const CustomFieldsCriterionOption = new CriterionOption({
type: "custom_fields",
messageID: "custom_fields.title",
makeCriterion: () => new CustomFieldsCriterion(),
});
export class CustomFieldsCriterion extends Criterion {
public value: CustomFieldCriterionInput[] = [];
constructor() {
super(CustomFieldsCriterionOption);
}
public isValid(): boolean {
return this.value.length > 0;
}
public applyToCriterionInput(input: Record<string, unknown>): void {
input.custom_fields = cloneDeep(this.value);
}
public getLabel(intl: IntlShape): string {
// show first criterion
if (this.value.length === 0) {
return "";
}
const first = this.value[0];
let messageID;
let valueString = "";
if (
first.modifier !== CriterionModifier.IsNull &&
first.modifier !== CriterionModifier.NotNull &&
(first.value?.length ?? 0) > 0
) {
valueString = (first.value![0] as string) ?? "";
}
const modifierString = ModifierCriterion.getModifierLabel(
intl,
first.modifier
);
const opts = {
criterion: first.field,
modifierString,
valueString,
others: "",
};
if (this.value.length === 1) {
messageID = "custom_fields.criteria_format_string";
} else {
messageID = "custom_fields.criteria_format_string_others";
opts.others = (this.value.length - 1).toString();
}
return intl.formatMessage({ id: messageID }, opts);
}
public getValueLabel(intl: IntlShape, v: CustomFieldCriterionInput): string {
let valueString = "";
if (
v.modifier !== CriterionModifier.IsNull &&
v.modifier !== CriterionModifier.NotNull &&
(v.value?.length ?? 0) > 0
) {
valueString = (v.value![0] as string) ?? "";
}
const modifierString = ModifierCriterion.getModifierLabel(intl, v.modifier);
const opts = {
criterion: v.field,
modifierString,
valueString,
others: "",
};
return intl.formatMessage(
{ id: "custom_fields.criteria_format_string" },
opts
);
}
public toJSON(): string {
const encodedCriterion = {
type: this.criterionOption.type,
value: this.value,
};
return JSON.stringify(encodedCriterion);
}
public setFromSavedCriterion(criterion: {
type: string;
value: CustomFieldCriterionInput[];
}): void {
const { value } = criterion;
this.value = cloneDeep(value);
}
}

View File

@@ -5,12 +5,12 @@ import {
} from "src/core/generated-graphql";
import { genderStrings, stringToGender } from "src/utils/gender";
import {
CriterionOption,
ModifierCriterionOption,
ISavedCriterion,
MultiStringCriterion,
} from "./criterion";
export const GenderCriterionOption = new CriterionOption({
export const GenderCriterionOption = new ModifierCriterionOption({
messageID: "gender",
type: "gender",
options: genderStrings,

View File

@@ -1,5 +1,8 @@
import { CriterionModifier } from "src/core/generated-graphql";
import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion";
import {
ModifierCriterionOption,
IHierarchicalLabeledIdCriterion,
} from "./criterion";
import { CriterionType } from "../types";
const inputType = "groups";
@@ -13,7 +16,7 @@ const modifierOptions = [
const defaultModifier = CriterionModifier.Includes;
class BaseGroupsCriterionOption extends CriterionOption {
class BaseGroupsCriterionOption extends ModifierCriterionOption {
constructor(messageID: string, type: CriterionType) {
super({
messageID,
@@ -44,7 +47,7 @@ export const SubGroupsCriterionOption = new BaseGroupsCriterionOption(
);
// redirects to GroupsCriterion
export const LegacyMoviesCriterionOption = new CriterionOption({
export const LegacyMoviesCriterionOption = new ModifierCriterionOption({
messageID: "groups",
type: "movies",
modifierOptions,

View File

@@ -1,6 +1,6 @@
import { CriterionModifier } from "src/core/generated-graphql";
import { CriterionType } from "../types";
import { CriterionOption, StringCriterion, Option } from "./criterion";
import { ModifierCriterionOption, StringCriterion, Option } from "./criterion";
export class IsMissingCriterion extends StringCriterion {
public toCriterionInput(): string {
@@ -8,7 +8,7 @@ export class IsMissingCriterion extends StringCriterion {
}
}
class IsMissingCriterionOption extends CriterionOption {
class IsMissingCriterionOption extends ModifierCriterionOption {
constructor(messageID: string, type: CriterionType, options: Option[]) {
super({
messageID,

View File

@@ -1,6 +1,6 @@
import { orientationStrings, stringToOrientation } from "src/utils/orientation";
import { CriterionType } from "../types";
import { CriterionOption, MultiStringCriterion } from "./criterion";
import { ModifierCriterionOption, MultiStringCriterion } from "./criterion";
import {
OrientationCriterionInput,
OrientationEnum,
@@ -16,7 +16,7 @@ export class OrientationCriterion extends MultiStringCriterion {
}
}
class BaseOrientationCriterionOption extends CriterionOption {
class BaseOrientationCriterionOption extends ModifierCriterionOption {
constructor(value: CriterionType) {
super({
messageID: value,

View File

@@ -5,7 +5,11 @@ import {
MultiCriterionInput,
} from "src/core/generated-graphql";
import { ILabeledId, ILabeledValueListValue } from "../types";
import { Criterion, CriterionOption, ISavedCriterion } from "./criterion";
import {
ModifierCriterion,
ModifierCriterionOption,
ISavedCriterion,
} from "./criterion";
const modifierOptions = [
CriterionModifier.IncludesAll,
@@ -19,7 +23,7 @@ const defaultModifier = CriterionModifier.IncludesAll;
const inputType = "performers";
export const PerformersCriterionOption = new CriterionOption({
export const PerformersCriterionOption = new ModifierCriterionOption({
messageID: "performers",
type: "performers",
modifierOptions,
@@ -28,7 +32,7 @@ export const PerformersCriterionOption = new CriterionOption({
makeCriterion: () => new PerformersCriterion(),
});
export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
export class PerformersCriterion extends ModifierCriterion<ILabeledValueListValue> {
constructor() {
super(PerformersCriterionOption, { items: [], excluded: [] });
}
@@ -116,7 +120,10 @@ export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
public getLabel(intl: IntlShape): string {
let id = "criterion_modifier.format_string";
let modifierString = Criterion.getModifierLabel(intl, this.modifier);
let modifierString = ModifierCriterion.getModifierLabel(
intl,
this.modifier
);
let valueString = "";
let excludedString = "";
@@ -128,7 +135,7 @@ export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
if (this.value.excluded && this.value.excluded.length > 0) {
if (this.value.items.length === 0) {
modifierString = Criterion.getModifierLabel(
modifierString = ModifierCriterion.getModifierLabel(
intl,
CriterionModifier.Excludes
);

View File

@@ -6,12 +6,12 @@ import {
import { IPhashDistanceValue } from "../types";
import {
BooleanCriterionOption,
Criterion,
CriterionOption,
ModifierCriterion,
ModifierCriterionOption,
StringCriterion,
} from "./criterion";
export const PhashCriterionOption = new CriterionOption({
export const PhashCriterionOption = new ModifierCriterionOption({
messageID: "media_info.phash",
type: "phash_distance",
inputType: "text",
@@ -24,7 +24,7 @@ export const PhashCriterionOption = new CriterionOption({
makeCriterion: () => new PhashCriterion(),
});
export class PhashCriterion extends Criterion<IPhashDistanceValue> {
export class PhashCriterion extends ModifierCriterion<IPhashDistanceValue> {
constructor() {
super(PhashCriterionOption, { value: "", distance: 0 });
}

View File

@@ -10,7 +10,7 @@ import {
IntCriterionInput,
} from "src/core/generated-graphql";
import { INumberValue } from "../types";
import { Criterion, CriterionOption } from "./criterion";
import { ModifierCriterion, ModifierCriterionOption } from "./criterion";
const modifierOptions = [
CriterionModifier.Equals,
@@ -27,7 +27,7 @@ function getRatingSystemOptions(config?: ConfigDataFragment) {
return config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;
}
export const RatingCriterionOption = new CriterionOption({
export const RatingCriterionOption = new ModifierCriterionOption({
messageID: "rating",
type: "rating100",
modifierOptions,
@@ -37,7 +37,7 @@ export const RatingCriterionOption = new CriterionOption({
inputType: "number",
});
export class RatingCriterion extends Criterion<INumberValue> {
export class RatingCriterion extends ModifierCriterion<INumberValue> {
ratingSystem: RatingSystemOptions;
constructor(ratingSystem: RatingSystemOptions) {

View File

@@ -5,16 +5,16 @@ import {
import { stringToResolution, resolutionStrings } from "src/utils/resolution";
import { CriterionType } from "../types";
import {
Criterion,
CriterionOption,
ModifierCriterion,
ModifierCriterionOption,
CriterionValue,
StringCriterion,
} from "./criterion";
class BaseResolutionCriterionOption extends CriterionOption {
class BaseResolutionCriterionOption extends ModifierCriterionOption {
constructor(
value: CriterionType,
makeCriterion: () => Criterion<CriterionValue>
makeCriterion: () => ModifierCriterion<CriterionValue>
) {
super({
messageID: value,

View File

@@ -1,5 +1,5 @@
import {
CriterionOption,
ModifierCriterionOption,
ILabeledIdCriterion,
ILabeledIdCriterionOption,
} from "./criterion";
@@ -28,7 +28,7 @@ const modifierOptions = [
const defaultModifier = CriterionModifier.Includes;
export const MarkersScenesCriterionOption = new CriterionOption({
export const MarkersScenesCriterionOption = new ModifierCriterionOption({
messageID: "scenes",
type: "scenes",
modifierOptions,

View File

@@ -5,9 +5,9 @@ import {
StashIdCriterionInput,
} from "src/core/generated-graphql";
import { IStashIDValue } from "../types";
import { Criterion, CriterionOption } from "./criterion";
import { ModifierCriterion, ModifierCriterionOption } from "./criterion";
export const StashIDCriterionOption = new CriterionOption({
export const StashIDCriterionOption = new ModifierCriterionOption({
messageID: "stash_id",
type: "stash_id_endpoint",
modifierOptions: [
@@ -19,7 +19,7 @@ export const StashIDCriterionOption = new CriterionOption({
makeCriterion: () => new StashIDCriterion(),
});
export class StashIDCriterion extends Criterion<IStashIDValue> {
export class StashIDCriterion extends ModifierCriterion<IStashIDValue> {
constructor() {
super(StashIDCriterionOption, {
endpoint: "",
@@ -56,7 +56,10 @@ export class StashIDCriterion extends Criterion<IStashIDValue> {
}
public getLabel(intl: IntlShape): string {
const modifierString = Criterion.getModifierLabel(intl, this.modifier);
const modifierString = ModifierCriterion.getModifierLabel(
intl,
this.modifier
);
let valueString = "";
if (

View File

@@ -1,6 +1,6 @@
import { CriterionModifier } from "src/core/generated-graphql";
import {
CriterionOption,
ModifierCriterionOption,
IHierarchicalLabeledIdCriterion,
ILabeledIdCriterion,
ILabeledIdCriterionOption,
@@ -15,7 +15,7 @@ const modifierOptions = [
const defaultModifier = CriterionModifier.Includes;
const inputType = "studios";
export const StudiosCriterionOption = new CriterionOption({
export const StudiosCriterionOption = new ModifierCriterionOption({
messageID: "studios",
type: "studios",
modifierOptions,

View File

@@ -1,5 +1,8 @@
import { CriterionModifier } from "src/core/generated-graphql";
import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion";
import {
ModifierCriterionOption,
IHierarchicalLabeledIdCriterion,
} from "./criterion";
import { CriterionType } from "../types";
const defaultModifierOptions = [
@@ -20,7 +23,7 @@ const withoutEqualsModifierOptions = [
const defaultModifier = CriterionModifier.IncludesAll;
const inputType = "tags";
class BaseTagsCriterionOption extends CriterionOption {
class BaseTagsCriterionOption extends ModifierCriterionOption {
constructor(
messageID: string,
type: CriterionType,

View File

@@ -11,13 +11,9 @@ import {
ISavedCriterion,
} from "./criteria/criterion";
import { getFilterOptions } from "./factory";
import {
CriterionType,
DisplayMode,
SavedObjectFilter,
SavedUIOptions,
} from "./types";
import { CriterionType, DisplayMode, SavedUIOptions } from "./types";
import { ListFilterOptions } from "./filter-options";
import { CustomFieldsCriterion } from "./criteria/custom-fields";
interface IDecodedParams {
perPage?: number;
@@ -60,7 +56,7 @@ export class ListFilterModel {
public sortBy?: string;
public displayMode: DisplayMode = DEFAULT_PARAMS.displayMode;
public zoomIndex: number = 1;
public criteria: Array<Criterion<CriterionValue>> = [];
public criteria: Array<Criterion> = [];
public randomSeed = -1;
private defaultZoomIndex: number = 1;
@@ -446,15 +442,7 @@ export class ListFilterModel {
public makeFilter() {
const output: Record<string, unknown> = {};
for (const c of this.criteria) {
output[c.criterionOption.type] = c.toCriterionInput();
}
return output;
}
public makeSavedFilter() {
const output: SavedObjectFilter = {};
for (const c of this.criteria) {
output[c.criterionOption.type] = c.toSavedCriterion();
c.applyToCriterionInput(output);
}
return output;
}
@@ -488,6 +476,20 @@ export class ListFilterModel {
return ret;
}
public removeCustomFieldCriterion(type: CriterionType, index: number) {
const ret = this.clone();
const c = ret.criteria.find((cc) => cc.criterionOption.type === type);
if (!c) return ret;
if (c instanceof CustomFieldsCriterion) {
const newCriteria = c.value.filter((_, i) => i !== index);
c.value = newCriteria;
}
return ret;
}
public setPageSize(pageSize: number) {
const ret = this.clone();
ret.itemsPerPage = pageSize;

View File

@@ -17,6 +17,7 @@ import { ListFilterOptions } from "./filter-options";
import { CriterionType, DisplayMode } from "./types";
import { CountryCriterionOption } from "./criteria/country";
import { RatingCriterionOption } from "./criteria/rating";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
const defaultSortBy = "name";
const sortByOptions = [
@@ -108,6 +109,7 @@ const criterionOptions = [
createDateCriterionOption("death_date"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
CustomFieldsCriterionOption,
];
export const PerformerListFilterOptions = new ListFilterOptions(
defaultSortBy,

View File

@@ -220,4 +220,5 @@ export type CriterionType =
| "photographer"
| "disambiguation"
| "has_chapters"
| "sort_name";
| "sort_name"
| "custom_fields";

View File

@@ -19,11 +19,12 @@ import {
SubGroupsCriterionOption,
} from "src/models/list-filter/criteria/groups";
import {
Criterion,
CriterionOption,
ModifierCriterion,
ModifierCriterionOption,
CriterionValue,
StringCriterion,
createStringCriterionOption,
Criterion,
} from "src/models/list-filter/criteria/criterion";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
@@ -33,10 +34,7 @@ import { galleryTitle } from "src/core/galleries";
import { MarkersScenesCriterion } from "src/models/list-filter/criteria/scenes";
import { objectTitle } from "src/core/files";
function addExtraCriteria(
dest: Criterion<CriterionValue>[],
src?: Criterion<CriterionValue>[]
) {
function addExtraCriteria(dest: Criterion[], src?: Criterion[]) {
if (src && src.length > 0) {
dest.push(...src);
}
@@ -45,7 +43,7 @@ function addExtraCriteria(
const makePerformerScenesUrl = (
performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
extraCriteria?: ModifierCriterion<CriterionValue>[]
) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
@@ -66,7 +64,7 @@ const makePerformerScenesUrl = (
const makePerformerImagesUrl = (
performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
extraCriteria?: ModifierCriterion<CriterionValue>[]
) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
@@ -93,7 +91,7 @@ export interface INamedObject {
const makePerformerGalleriesUrl = (
performer: INamedObject,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
extraCriteria?: ModifierCriterion<CriterionValue>[]
) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);
@@ -114,7 +112,7 @@ const makePerformerGalleriesUrl = (
const makePerformerGroupsUrl = (
performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
extraCriteria?: ModifierCriterion<CriterionValue>[]
) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);
@@ -346,7 +344,7 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe<string> | undefined) => {
const makeGalleryImagesUrl = (
gallery: Partial<GQL.GalleryDataFragment | GQL.SlimGalleryDataFragment>,
extraCriteria?: Criterion<CriterionValue>[]
extraCriteria?: ModifierCriterion<CriterionValue>[]
) => {
if (!gallery.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
@@ -357,7 +355,7 @@ const makeGalleryImagesUrl = (
return `/images?${filter.makeQueryParameters()}`;
};
function stringEqualsCriterion(option: CriterionOption, value: string) {
function stringEqualsCriterion(option: ModifierCriterionOption, value: string) {
const criterion = new StringCriterion(option);
criterion.modifier = GQL.CriterionModifier.Equals;
criterion.value = value;