diff --git a/ui/v2.5/src/components/Galleries/GallerySelect.tsx b/ui/v2.5/src/components/Galleries/GallerySelect.tsx index fe909238b..4cd8825bb 100644 --- a/ui/v2.5/src/components/Galleries/GallerySelect.tsx +++ b/ui/v2.5/src/components/Galleries/GallerySelect.tsx @@ -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; type ExtraGalleryProps = { hoverPlacement?: Placement; excludeIds?: string[]; - extraCriteria?: Array>; + extraCriteria?: Array>; }; type FindGalleriesResult = Awaited< diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index ffc707807..eba212223 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -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; - setCriterion: (c: Criterion) => void; + criterion: ModifierCriterion; + setCriterion: (c: ModifierCriterion) => void; } const GenericCriterionEditor: React.FC = ({ 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 = ({ } return ( - - {modifierOptions.map((m) => ( - - ))} - + ); }, [ showModifierSelector, modifierOptions, onChangedModifierSelect, criterion.modifier, - intl, ]); const valueControl = useMemo(() => { @@ -268,8 +258,8 @@ const GenericCriterionEditor: React.FC = ({ }; interface ICriterionEditor { - criterion: Criterion; - setCriterion: (c: Criterion) => void; + criterion: Criterion; + setCriterion: (c: Criterion) => void; } export const CriterionEditor: React.FC = ({ @@ -283,12 +273,22 @@ export const CriterionEditor: React.FC = ({ ); } - return ( - - ); + if (criterion instanceof CustomFieldsCriterion) { + return ( + + ); + } + + if (criterion instanceof ModifierCriterion) { + return ( + + ); + } + + return null; }, [criterion, setCriterion]); return
{filterControl}
; diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 43546731f..32a0b3610 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -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; - setCriterion: (c: Criterion) => void; + currentCriterion?: Criterion; + setCriterion: (c: Criterion) => void; criterionOptions: CriterionOption[]; pinnedCriterionOptions: CriterionOption[]; selected?: CriterionOption; @@ -228,7 +227,7 @@ export const EditFilterDialog: React.FC = ({ const [currentFilter, setCurrentFilter] = useState( cloneDeep(filter) ); - const [criterion, setCriterion] = useState>(); + const [criterion, setCriterion] = useState(); const [searchRef, setSearchFocus] = useFocusOnce(!ScreenUtils.isTouch()); @@ -364,7 +363,7 @@ export const EditFilterDialog: React.FC = ({ } } - function replaceCriterion(c: Criterion) { + function replaceCriterion(c: Criterion) { const newFilter = cloneDeep(currentFilter); if (!c.isValid()) { @@ -397,18 +396,26 @@ export const EditFilterDialog: React.FC = ({ setCriterion(c); } - function removeCriterion(c: Criterion) { - 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 = ({ optionSelected(c.criterionOption)} - onRemoveCriterion={(c) => removeCriterion(c)} + onRemoveCriterion={removeCriterion} onRemoveAll={() => onClearAll()} /> diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index 779fa26be..a384f05ca 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -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 = (props) => { + const { children } = props; + return ( + + {children} + + ); +}; + +export const FilterTag: React.FC<{ + label: React.ReactNode; + onClick: React.MouseEventHandler; + onRemove: React.MouseEventHandler; +}> = ({ label, onClick, onRemove }) => { + return ( + + {label} + + + ); +}; interface IFilterTagsProps { - criteria: Criterion[]; - onEditCriterion: (c: Criterion) => void; - onRemoveCriterion: (c: Criterion) => void; + criteria: Criterion[]; + onEditCriterion: (c: Criterion) => void; + onRemoveCriterion: (c: Criterion, valueIndex?: number) => void; onRemoveAll: () => void; } @@ -24,59 +57,62 @@ export const FilterTags: React.FC = ({ const intl = useIntl(); function onRemoveCriterionTag( - criterion: Criterion, - $event: React.MouseEvent + criterion: Criterion, + $event: React.MouseEvent, + valueIndex?: number ) { if (!criterion) { return; } - onRemoveCriterion(criterion); + onRemoveCriterion(criterion, valueIndex); $event.stopPropagation(); } - function onClickCriterionTag(criterion: Criterion) { + function onClickCriterionTag(criterion: Criterion) { onEditCriterion(criterion); } - function renderFilterTags() { - return criteria.map((criterion) => ( - onClickCriterionTag(criterion)} - > - {criterion.getLabel(intl)} - - - )); - } - - 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 ( + onClickCriterionTag(criterion)} + onRemove={($event) => + onRemoveCriterionTag(criterion, $event, index) + } + /> + ); + }); } return ( - + onClickCriterionTag(criterion)} + onRemove={($event) => onRemoveCriterionTag(criterion, $event)} + /> ); } return (
- {renderFilterTags()} - {maybeRenderClearAll()} + {criteria.map(renderFilterTags)} + {criteria.length >= 3 && ( + + )}
); }; diff --git a/ui/v2.5/src/components/List/Filters/CustomFieldsFilter.tsx b/ui/v2.5/src/components/List/Filters/CustomFieldsFilter.tsx new file mode 100644 index 000000000..0721cfe00 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/CustomFieldsFilter.tsx @@ -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 = ({ + 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 ( + +
+ + + setField(e.target.value)} + value={field} + /> + + + onChangeModifier(m)} + /> + + + + {modifier !== CriterionModifier.IsNull && + modifier !== CriterionModifier.NotNull && ( + + setFirstValue(e.target.value)} + value={firstValue} + /> + + )} + {(modifier === CriterionModifier.Between || + modifier === CriterionModifier.NotBetween) && ( + + setSecondValue(e.target.value)} + value={secondValue} + /> + + )} + +
+
+ + {editing && ( + + )} +
+
+ ); +}; + +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 ( + + + {str} + + ); + } + + return <>{str}; + }, [criterion, editing, intl]); + + return ( + + ); +}; + +const CustomFieldsCriteriaPills: React.FC<{ + criteria: CustomFieldCriterionInput[]; + editIndex?: number; + onEditCriterion: (index: number) => void; + onRemoveCriterion: (index: number) => void; +}> = ({ criteria, editIndex, onEditCriterion, onRemoveCriterion }) => { + return ( +
+ {criteria.map((c, index) => ( + onEditCriterion(index)} + onRemoveCriterion={() => onRemoveCriterion(index)} + /> + ))} +
+ ); +}; + +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 = ({ + 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 ( + + setEditCriterion(createNewCriterion())} + /> + + setEditCriterion(localCriterion.value[index]) + } + onRemoveCriterion={(index) => onRemove(index)} + /> + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/DateFilter.tsx b/ui/v2.5/src/components/List/Filters/DateFilter.tsx index bbedfdfee..768421e23 100644 --- a/ui/v2.5/src/components/List/Filters/DateFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/DateFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: IDateValue) => void; } diff --git a/ui/v2.5/src/components/List/Filters/DurationFilter.tsx b/ui/v2.5/src/components/List/Filters/DurationFilter.tsx index 084bb6eed..ee52036eb 100644 --- a/ui/v2.5/src/components/List/Filters/DurationFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/DurationFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: INumberValue) => void; } diff --git a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx index c51c8a2d5..f3a646161 100644 --- a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx @@ -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; + criterion: ModifierCriterion; 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(); diff --git a/ui/v2.5/src/components/List/Filters/InputFilter.tsx b/ui/v2.5/src/components/List/Filters/InputFilter.tsx index 46ee7b6b0..eec304603 100644 --- a/ui/v2.5/src/components/List/Filters/InputFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/InputFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: string) => void; } @@ -23,7 +23,7 @@ export const InputFilter: React.FC = ({ diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 888cfe401..9e207c263 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: ILabeledId[]) => void; } @@ -15,7 +15,7 @@ export const LabeledIdFilter: React.FC = ({ criterion, onValueChanged, }) => { - const { criterionOption } = criterion; + const criterionOption = criterion.modifierCriterionOption(); const { inputType } = criterionOption; if ( diff --git a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx index dad0e38cc..d9cfaf733 100644 --- a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx @@ -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; - setCriterion: (c: Criterion) => void; + criterion: ModifierCriterion; + setCriterion: (c: ModifierCriterion) => void; } export const OptionFilter: React.FC = ({ @@ -26,7 +26,7 @@ export const OptionFilter: React.FC = ({ setCriterion(c); } - const { options } = criterion.criterionOption; + const { options } = criterion.modifierCriterionOption(); return (
@@ -45,8 +45,8 @@ export const OptionFilter: React.FC = ({ }; interface IOptionsListFilter { - criterion: Criterion; - setCriterion: (c: Criterion) => void; + criterion: ModifierCriterion; + setCriterion: (c: ModifierCriterion) => void; } export const OptionListFilter: React.FC = ({ @@ -65,7 +65,7 @@ export const OptionListFilter: React.FC = ({ setCriterion(c); } - const { options } = criterion.criterionOption; + const { options } = criterion.modifierCriterionOption(); const value = criterion.value as string[]; return ( diff --git a/ui/v2.5/src/components/List/Filters/PathFilter.tsx b/ui/v2.5/src/components/List/Filters/PathFilter.tsx index b6f965d6b..97711ebef 100644 --- a/ui/v2.5/src/components/List/Filters/PathFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PathFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: string) => void; } @@ -30,7 +30,7 @@ export const PathFilter: React.FC = ({ {regex ? ( onValueChanged(v.target.value)} value={criterion.value ? criterion.value.toString() : ""} /> diff --git a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx index a42a61287..89feb0008 100644 --- a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: IPhashDistanceValue) => void; } diff --git a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx index 73bc7b402..185ec90c3 100644 --- a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: INumberValue) => void; } diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index a53dc6eff..9ea4333da 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -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 = ({ ); }; -interface IObjectsFilter> { +interface IObjectsFilter> { criterion: T; setCriterion: (criterion: T) => void; useResults: (query: string) => { results: ILabeledId[]; loading: boolean }; @@ -324,7 +324,7 @@ interface IObjectsFilter> { } export const ObjectsFilter = < - T extends Criterion + T extends ModifierCriterion >({ 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 ( ; + criterion: ModifierCriterion; onValueChanged: (value: IStashIDValue) => void; } diff --git a/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx index 1cb25b7d5..2d83d5de7 100644 --- a/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx @@ -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; + criterion: ModifierCriterion; onValueChanged: (value: ITimestampValue) => void; } diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 05639e8fb..bf937b2b4 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -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 = ( result.refetch(); } - function onRemoveCriterion(removedCriterion: Criterion) { - 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() { diff --git a/ui/v2.5/src/components/List/ModifierSelect.tsx b/ui/v2.5/src/components/List/ModifierSelect.tsx new file mode 100644 index 000000000..f7d5e80ae --- /dev/null +++ b/ui/v2.5/src/components/List/ModifierSelect.tsx @@ -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 = ({ + options = defaultOptions, + value, + onChanged, +}) => { + const intl = useIntl(); + + return ( + + {options.map((m) => ( + + ))} + + ); +}; + +export const ModifierSelect: React.FC = ({ + options = defaultOptions, + value, + onChanged, +}) => { + const intl = useIntl(); + + return ( + onChanged(e.target.value as CriterionModifier)} + value={value} + className="btn-secondary modifier-selector" + > + {options.map((m) => ( + + ))} + + ); +}; diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index 63654c028..5a6bc91cb 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -67,7 +67,7 @@ export const SavedFilterList: React.FC = ({ 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 = ({ value: { mode: filter.mode, find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFilter(), + object_filter: filterCopy.makeFilter(), ui_options: filterCopy.makeSavedUIOptions(), }, }, diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index f234e7511..5e00f52b2 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -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; + } + } + } +} diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 04e7fe912..d6ab20440 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -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[]; - images?: Criterion[]; - galleries?: Criterion[]; - groups?: Criterion[]; + scenes?: ModifierCriterion[]; + images?: ModifierCriterion[]; + galleries?: ModifierCriterion[]; + groups?: ModifierCriterion[]; performer?: ILabeledId; } diff --git a/ui/v2.5/src/components/Scenes/SceneSelect.tsx b/ui/v2.5/src/components/Scenes/SceneSelect.tsx index fc7b3dec9..7871bc43e 100644 --- a/ui/v2.5/src/components/Scenes/SceneSelect.tsx +++ b/ui/v2.5/src/components/Scenes/SceneSelect.tsx @@ -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; type ExtraSceneProps = { hoverPlacement?: Placement; excludeIds?: string[]; - extraCriteria?: Array>; + extraCriteria?: Array>; }; type FindScenesResult = Awaited< diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 7570a4b65..925cafb66 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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" diff --git a/ui/v2.5/src/models/list-filter/criteria/captions.ts b/ui/v2.5/src/models/list-filter/criteria/captions.ts index f7468167d..f5eba4804 100644 --- a/ui/v2.5/src/models/list-filter/criteria/captions.ts +++ b/ui/v2.5/src/models/list-filter/criteria/captions.ts @@ -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: [ diff --git a/ui/v2.5/src/models/list-filter/criteria/circumcised.ts b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts index ce6f5bf5c..b1419ee8b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/circumcised.ts +++ b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts @@ -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: [ diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index b55e3c1f7..984dfba3d 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -57,10 +57,41 @@ const modifierMessageIDs = { [CriterionModifier.NotBetween]: "criterion_modifier.not_between", }; -// V = criterion value type -export abstract class Criterion { +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): 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 { 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 { } 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 { ); } - 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 { this.modifier = criterion.modifier; } - public toCriterionInput(): unknown { - return { - value: this.value, - modifier: this.modifier, - }; + public applyToCriterionInput(input: Record) { + input[this.criterionOption.type] = this.toCriterionInput(); } - public toSavedCriterion(): ISavedCriterion { + 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; } + 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; - - 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 + makeCriterion?: () => ModifierCriterion ) { const modifierOptions = [ CriterionModifier.Includes, @@ -264,8 +304,8 @@ export class ILabeledIdCriterionOption extends CriterionOption { } } -export class ILabeledIdCriterion extends Criterion { - constructor(type: CriterionOption, value: ILabeledId[] = []) { +export class ILabeledIdCriterion extends ModifierCriterion { + constructor(type: ModifierCriterionOption, value: ILabeledId[] = []) { super(type, value); } @@ -296,9 +336,9 @@ export class ILabeledIdCriterion extends Criterion { } } -export class IHierarchicalLabeledIdCriterion extends Criterion { +export class IHierarchicalLabeledIdCriterion extends ModifierCriterion { constructor( - type: CriterionOption, + type: ModifierCriterionOption, value: IHierarchicalLabelValue = { items: [], excluded: [], @@ -346,14 +386,16 @@ export class IHierarchicalLabeledIdCriterion extends Criterion 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 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 Criterion + makeCriterion?: () => ModifierCriterion ) { 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 { - constructor(type: CriterionOption) { +export class StringCriterion extends ModifierCriterion { + constructor(type: ModifierCriterionOption) { super(type, ""); } @@ -528,8 +573,8 @@ export class StringCriterion extends Criterion { } } -export abstract class MultiStringCriterion extends Criterion { - constructor(type: CriterionOption, value: string[] = []) { +export abstract class MultiStringCriterion extends ModifierCriterion { + constructor(type: ModifierCriterionOption, value: string[] = []) { super(type, value); } @@ -550,11 +595,11 @@ export abstract class MultiStringCriterion extends Criterion { } } -export class BooleanCriterionOption extends CriterionOption { +export class BooleanCriterionOption extends ModifierCriterionOption { constructor( messageID: string, value: CriterionType, - makeCriterion?: () => Criterion + makeCriterion?: () => ModifierCriterion ) { 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 + makeCriterion?: () => ModifierCriterion ) { 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 + 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 + makeCriterion?: () => ModifierCriterion ) { super({ messageID, @@ -710,8 +755,8 @@ export function createMandatoryNumberCriterionOption( return new MandatoryNumberCriterionOption(messageID ?? value, value); } -export class NumberCriterion extends Criterion { - constructor(type: CriterionOption) { +export class NumberCriterion extends ModifierCriterion { + 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 { - constructor(type: CriterionOption) { +export class DurationCriterion extends ModifierCriterion { + constructor(type: ModifierCriterionOption) { super(type, { value: undefined, value2: undefined }); } @@ -860,7 +905,7 @@ export class DurationCriterion extends Criterion { } } -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 { - constructor(type: CriterionOption) { +export class DateCriterion extends ModifierCriterion { + constructor(type: ModifierCriterionOption) { super(type, { value: "", value2: undefined }); } @@ -943,7 +988,7 @@ export class DateCriterion extends Criterion { } } -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 { - constructor(type: CriterionOption) { +export class TimestampCriterion extends ModifierCriterion { + constructor(type: ModifierCriterionOption) { super(type, { value: "", value2: undefined }); } diff --git a/ui/v2.5/src/models/list-filter/criteria/custom-fields.ts b/ui/v2.5/src/models/list-filter/criteria/custom-fields.ts new file mode 100644 index 000000000..7a4503cbe --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/custom-fields.ts @@ -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): 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); + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/gender.ts b/ui/v2.5/src/models/list-filter/criteria/gender.ts index 31e5a38ac..6512ae7ff 100644 --- a/ui/v2.5/src/models/list-filter/criteria/gender.ts +++ b/ui/v2.5/src/models/list-filter/criteria/gender.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/criteria/groups.ts b/ui/v2.5/src/models/list-filter/criteria/groups.ts index 0db384c6a..de7eeb2c1 100644 --- a/ui/v2.5/src/models/list-filter/criteria/groups.ts +++ b/ui/v2.5/src/models/list-filter/criteria/groups.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 99b53bc7d..f7387e558 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/criteria/orientation.ts b/ui/v2.5/src/models/list-filter/criteria/orientation.ts index 01e25e5ff..49cc86e68 100644 --- a/ui/v2.5/src/models/list-filter/criteria/orientation.ts +++ b/ui/v2.5/src/models/list-filter/criteria/orientation.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/criteria/performers.ts b/ui/v2.5/src/models/list-filter/criteria/performers.ts index e5d8178e0..caf150008 100644 --- a/ui/v2.5/src/models/list-filter/criteria/performers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/performers.ts @@ -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 { +export class PerformersCriterion extends ModifierCriterion { constructor() { super(PerformersCriterionOption, { items: [], excluded: [] }); } @@ -116,7 +120,10 @@ export class PerformersCriterion extends Criterion { 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 { if (this.value.excluded && this.value.excluded.length > 0) { if (this.value.items.length === 0) { - modifierString = Criterion.getModifierLabel( + modifierString = ModifierCriterion.getModifierLabel( intl, CriterionModifier.Excludes ); diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts index e1119bb65..0cbfa155e 100644 --- a/ui/v2.5/src/models/list-filter/criteria/phash.ts +++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts @@ -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 { +export class PhashCriterion extends ModifierCriterion { constructor() { super(PhashCriterionOption, { value: "", distance: 0 }); } diff --git a/ui/v2.5/src/models/list-filter/criteria/rating.ts b/ui/v2.5/src/models/list-filter/criteria/rating.ts index ef18a7f2b..cc5d7e091 100644 --- a/ui/v2.5/src/models/list-filter/criteria/rating.ts +++ b/ui/v2.5/src/models/list-filter/criteria/rating.ts @@ -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 { +export class RatingCriterion extends ModifierCriterion { ratingSystem: RatingSystemOptions; constructor(ratingSystem: RatingSystemOptions) { diff --git a/ui/v2.5/src/models/list-filter/criteria/resolution.ts b/ui/v2.5/src/models/list-filter/criteria/resolution.ts index e18ff942b..fc5546df9 100644 --- a/ui/v2.5/src/models/list-filter/criteria/resolution.ts +++ b/ui/v2.5/src/models/list-filter/criteria/resolution.ts @@ -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 + makeCriterion: () => ModifierCriterion ) { super({ messageID: value, diff --git a/ui/v2.5/src/models/list-filter/criteria/scenes.ts b/ui/v2.5/src/models/list-filter/criteria/scenes.ts index 346838279..a9aea56fb 100644 --- a/ui/v2.5/src/models/list-filter/criteria/scenes.ts +++ b/ui/v2.5/src/models/list-filter/criteria/scenes.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts index 94d53ed92..2d5ecd313 100644 --- a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts +++ b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts @@ -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 { +export class StashIDCriterion extends ModifierCriterion { constructor() { super(StashIDCriterionOption, { endpoint: "", @@ -56,7 +56,10 @@ export class StashIDCriterion extends Criterion { } public getLabel(intl: IntlShape): string { - const modifierString = Criterion.getModifierLabel(intl, this.modifier); + const modifierString = ModifierCriterion.getModifierLabel( + intl, + this.modifier + ); let valueString = ""; if ( diff --git a/ui/v2.5/src/models/list-filter/criteria/studios.ts b/ui/v2.5/src/models/list-filter/criteria/studios.ts index e6775d57f..c6ab8ab24 100644 --- a/ui/v2.5/src/models/list-filter/criteria/studios.ts +++ b/ui/v2.5/src/models/list-filter/criteria/studios.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index 0dd5d54e3..030df217e 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 183b630a2..fda673149 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -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> = []; + public criteria: Array = []; public randomSeed = -1; private defaultZoomIndex: number = 1; @@ -446,15 +442,7 @@ export class ListFilterModel { public makeFilter() { const output: Record = {}; 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; diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 81a732dea..88a52c22a 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 93d24765a..737965d1e 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -220,4 +220,5 @@ export type CriterionType = | "photographer" | "disambiguation" | "has_chapters" - | "sort_name"; + | "sort_name" + | "custom_fields"; diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index b67c3ad46..581d079c7 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -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[], - src?: Criterion[] -) { +function addExtraCriteria(dest: Criterion[], src?: Criterion[]) { if (src && src.length > 0) { dest.push(...src); } @@ -45,7 +43,7 @@ function addExtraCriteria( const makePerformerScenesUrl = ( performer: Partial, extraPerformer?: ILabeledId, - extraCriteria?: Criterion[] + extraCriteria?: ModifierCriterion[] ) => { if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); @@ -66,7 +64,7 @@ const makePerformerScenesUrl = ( const makePerformerImagesUrl = ( performer: Partial, extraPerformer?: ILabeledId, - extraCriteria?: Criterion[] + extraCriteria?: ModifierCriterion[] ) => { 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[] + extraCriteria?: ModifierCriterion[] ) => { if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); @@ -114,7 +112,7 @@ const makePerformerGalleriesUrl = ( const makePerformerGroupsUrl = ( performer: Partial, extraPerformer?: ILabeledId, - extraCriteria?: Criterion[] + extraCriteria?: ModifierCriterion[] ) => { if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined); @@ -346,7 +344,7 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe | undefined) => { const makeGalleryImagesUrl = ( gallery: Partial, - extraCriteria?: Criterion[] + extraCriteria?: ModifierCriterion[] ) => { 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;