From 943a6d3be785cce2d3df5bfc577b9133bdcff8e0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 16 Mar 2023 15:44:46 +1100 Subject: [PATCH] UI filter builder (#3515) * Add clear criteria button * Add count to filter button --- .../src/components/List/AddFilterDialog.tsx | 348 ------------------ .../src/components/List/CriterionEditor.tsx | 218 +++++++++++ .../src/components/List/EditFilterDialog.tsx | 340 +++++++++++++++++ ui/v2.5/src/components/List/FilterTags.tsx | 23 +- .../components/List/Filters/BooleanFilter.tsx | 45 +++ .../components/List/Filters/DateFilter.tsx | 26 +- .../components/List/Filters/FilterButton.tsx | 28 ++ .../Filters/HierarchicalLabelValueFilter.tsx | 1 + .../components/List/Filters/InputFilter.tsx | 18 +- .../List/Filters/LabeledIdFilter.tsx | 1 + .../components/List/Filters/NumberFilter.tsx | 16 +- .../components/List/Filters/OptionsFilter.tsx | 19 +- .../List/Filters/OptionsListFilter.tsx | 45 +++ .../components/List/Filters/StashIDFilter.tsx | 9 +- .../List/Filters/TimestampFilter.tsx | 32 +- ui/v2.5/src/components/List/ItemList.tsx | 84 ++--- ui/v2.5/src/components/List/ListFilter.tsx | 12 +- ui/v2.5/src/components/List/styles.scss | 80 ++++ ui/v2.5/src/docs/en/Changelog/v0200.md | 1 + ui/v2.5/src/hooks/state.ts | 15 + ui/v2.5/src/index.scss | 6 +- ui/v2.5/src/locales/en-GB.json | 2 +- .../models/list-filter/criteria/criterion.ts | 146 +++++++- .../src/models/list-filter/criteria/rating.ts | 2 +- .../models/list-filter/criteria/stash-ids.ts | 8 + ui/v2.5/src/models/list-filter/filter.ts | 10 + ui/v2.5/src/models/list-filter/types.ts | 2 +- 27 files changed, 1049 insertions(+), 488 deletions(-) delete mode 100644 ui/v2.5/src/components/List/AddFilterDialog.tsx create mode 100644 ui/v2.5/src/components/List/CriterionEditor.tsx create mode 100644 ui/v2.5/src/components/List/EditFilterDialog.tsx create mode 100644 ui/v2.5/src/components/List/Filters/BooleanFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/FilterButton.tsx create mode 100644 ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx diff --git a/ui/v2.5/src/components/List/AddFilterDialog.tsx b/ui/v2.5/src/components/List/AddFilterDialog.tsx deleted file mode 100644 index c7c1e43c59b..00000000000 --- a/ui/v2.5/src/components/List/AddFilterDialog.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import cloneDeep from "lodash-es/cloneDeep"; -import React, { useContext, useEffect, useRef, useState } from "react"; -import { Button, Form, Modal } from "react-bootstrap"; -import { CriterionModifier } from "src/core/generated-graphql"; -import { - DurationCriterion, - CriterionValue, - Criterion, - IHierarchicalLabeledIdCriterion, - NumberCriterion, - ILabeledIdCriterion, - DateCriterion, - TimestampCriterion, -} from "src/models/list-filter/criteria/criterion"; -import { - NoneCriterion, - NoneCriterionOption, -} from "src/models/list-filter/criteria/none"; -import { makeCriteria } from "src/models/list-filter/criteria/factory"; -import { ListFilterOptions } from "src/models/list-filter/filter-options"; -import { FormattedMessage, useIntl } from "react-intl"; -import { - criterionIsHierarchicalLabelValue, - criterionIsNumberValue, - criterionIsStashIDValue, - criterionIsDateValue, - criterionIsTimestampValue, - CriterionType, -} from "src/models/list-filter/types"; -import { DurationFilter } from "./Filters/DurationFilter"; -import { NumberFilter } from "./Filters/NumberFilter"; -import { LabeledIdFilter } from "./Filters/LabeledIdFilter"; -import { HierarchicalLabelValueFilter } from "./Filters/HierarchicalLabelValueFilter"; -import { OptionsFilter } from "./Filters/OptionsFilter"; -import { InputFilter } from "./Filters/InputFilter"; -import { DateFilter } from "./Filters/DateFilter"; -import { TimestampFilter } from "./Filters/TimestampFilter"; -import { CountryCriterion } from "src/models/list-filter/criteria/country"; -import { CountrySelect } from "../Shared/CountrySelect"; -import { StashIDCriterion } from "src/models/list-filter/criteria/stash-ids"; -import { StashIDFilter } from "./Filters/StashIDFilter"; -import { ConfigurationContext } from "src/hooks/Config"; -import { RatingCriterion } from "../../models/list-filter/criteria/rating"; -import { RatingFilter } from "./Filters/RatingFilter"; - -interface IAddFilterProps { - onAddCriterion: ( - criterion: Criterion, - oldId?: string - ) => void; - onCancel: () => void; - filterOptions: ListFilterOptions; - editingCriterion?: Criterion; - existingCriterions: Criterion[]; -} - -export const AddFilterDialog: React.FC = ({ - onAddCriterion, - onCancel, - filterOptions, - editingCriterion, - existingCriterions, -}) => { - const defaultValue = useRef(); - - const [criterion, setCriterion] = useState>( - new NoneCriterion() - ); - const { options, modifierOptions } = criterion.criterionOption; - - const valueStage = useRef(criterion.value); - const { configuration: config } = useContext(ConfigurationContext); - - const intl = useIntl(); - - // Configure if we are editing an existing criterion - useEffect(() => { - if (!editingCriterion) { - setCriterion(makeCriteria(config)); - } else { - setCriterion(editingCriterion); - } - }, [config, editingCriterion]); - - useEffect(() => { - valueStage.current = criterion.value; - }, [criterion]); - - function onChangedCriteriaType(event: React.ChangeEvent) { - const newCriterionType = event.target.value as CriterionType; - const newCriterion = makeCriteria(config, newCriterionType); - setCriterion(newCriterion); - } - - function onChangedModifierSelect( - event: React.ChangeEvent - ) { - const newCriterion = cloneDeep(criterion); - newCriterion.modifier = event.target.value as CriterionModifier; - setCriterion(newCriterion); - } - - function onValueChanged(value: CriterionValue) { - const newCriterion = cloneDeep(criterion); - newCriterion.value = value; - setCriterion(newCriterion); - } - - function onAddFilter() { - const oldId = editingCriterion ? editingCriterion.getId() : undefined; - onAddCriterion(criterion, oldId); - } - - const maybeRenderFilterPopoverContents = () => { - if (criterion.criterionOption.type === "none") { - return; - } - - function renderModifier() { - if (modifierOptions.length === 0) { - return; - } - return ( - - {modifierOptions.map((c) => ( - - ))} - - ); - } - - function renderSelect() { - // always show stashID filter - if (criterion instanceof StashIDCriterion) { - return ( - - ); - } - - // Hide the value select if the modifier is "IsNull" or "NotNull" - if ( - criterion.modifier === CriterionModifier.IsNull || - criterion.modifier === CriterionModifier.NotNull - ) { - return; - } - - if (criterion instanceof ILabeledIdCriterion) { - return ( - - ); - } - if (criterion instanceof IHierarchicalLabeledIdCriterion) { - return ( - - ); - } - if ( - options && - !criterionIsHierarchicalLabelValue(criterion.value) && - !criterionIsNumberValue(criterion.value) && - !criterionIsStashIDValue(criterion.value) && - !criterionIsDateValue(criterion.value) && - !criterionIsTimestampValue(criterion.value) && - !Array.isArray(criterion.value) - ) { - defaultValue.current = criterion.value; - return ( - - ); - } - if (criterion instanceof DurationCriterion) { - return ( - - ); - } - if (criterion instanceof DateCriterion) { - return ( - - ); - } - if (criterion instanceof TimestampCriterion) { - return ( - - ); - } - if (criterion instanceof NumberCriterion) { - return ( - - ); - } - if (criterion instanceof RatingCriterion) { - return ( - - ); - } - if ( - criterion instanceof CountryCriterion && - (criterion.modifier === CriterionModifier.Equals || - criterion.modifier === CriterionModifier.NotEquals) - ) { - return ( - onValueChanged(v)} - /> - ); - } - return ( - - ); - } - return ( - <> - {renderModifier()} - {renderSelect()} - - ); - }; - - function maybeRenderFilterCriterion() { - if (!editingCriterion) { - return; - } - - return ( - - - {intl.formatMessage({ - id: editingCriterion.criterionOption.messageID, - })} - - - ); - } - - function maybeRenderFilterSelect() { - if (editingCriterion) { - return; - } - - const thisOptions = [NoneCriterionOption] - .concat(filterOptions.criterionOptions) - .filter( - (c) => - !existingCriterions.find((ec) => ec.criterionOption.type === c.type) - ) - .map((c) => { - return { - value: c.type, - text: intl.formatMessage({ id: c.messageID }), - }; - }) - .sort((a, b) => { - if (a.value === "none") return -1; - if (b.value === "none") return 1; - return a.text.localeCompare(b.text); - }); - - return ( - - - - - - {thisOptions.map((c) => ( - - ))} - - - ); - } - - function isValid() { - if (criterion.criterionOption.type === "none") { - return false; - } - - if (criterion instanceof RatingCriterion) { - switch (criterion.modifier) { - case CriterionModifier.Equals: - case CriterionModifier.NotEquals: - case CriterionModifier.LessThan: - return !!criterion.value.value; - case CriterionModifier.Between: - case CriterionModifier.NotBetween: - return criterion.value.value < (criterion.value.value2 ?? 0); - } - } - - return true; - } - - const title = !editingCriterion - ? intl.formatMessage({ id: "search_filter.add_filter" }) - : intl.formatMessage({ id: "search_filter.update_filter" }); - return ( - <> - onCancel()} className="add-filter-dialog"> - {title} - -
- {maybeRenderFilterSelect()} - {maybeRenderFilterCriterion()} - {maybeRenderFilterPopoverContents()} -
-
- - - -
- - ); -}; diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx new file mode 100644 index 00000000000..1c67cf977c7 --- /dev/null +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -0,0 +1,218 @@ +import cloneDeep from "lodash-es/cloneDeep"; +import React, { useCallback, useMemo } from "react"; +import { Form } from "react-bootstrap"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { + DurationCriterion, + CriterionValue, + Criterion, + IHierarchicalLabeledIdCriterion, + NumberCriterion, + ILabeledIdCriterion, + DateCriterion, + TimestampCriterion, + BooleanCriterion, +} from "src/models/list-filter/criteria/criterion"; +import { useIntl } from "react-intl"; +import { + criterionIsHierarchicalLabelValue, + criterionIsNumberValue, + criterionIsStashIDValue, + criterionIsDateValue, + criterionIsTimestampValue, +} from "src/models/list-filter/types"; +import { DurationFilter } from "./Filters/DurationFilter"; +import { NumberFilter } from "./Filters/NumberFilter"; +import { LabeledIdFilter } from "./Filters/LabeledIdFilter"; +import { HierarchicalLabelValueFilter } from "./Filters/HierarchicalLabelValueFilter"; +import { InputFilter } from "./Filters/InputFilter"; +import { DateFilter } from "./Filters/DateFilter"; +import { TimestampFilter } from "./Filters/TimestampFilter"; +import { CountryCriterion } from "src/models/list-filter/criteria/country"; +import { CountrySelect } from "../Shared/CountrySelect"; +import { StashIDCriterion } from "src/models/list-filter/criteria/stash-ids"; +import { StashIDFilter } from "./Filters/StashIDFilter"; +import { RatingCriterion } from "../../models/list-filter/criteria/rating"; +import { RatingFilter } from "./Filters/RatingFilter"; +import { BooleanFilter } from "./Filters/BooleanFilter"; +import { OptionsListFilter } from "./Filters/OptionsListFilter"; + +interface IGenericCriterionEditor { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +const GenericCriterionEditor: React.FC = ({ + criterion, + setCriterion, +}) => { + const intl = useIntl(); + + const { options, modifierOptions } = criterion.criterionOption; + + const onChangedModifierSelect = useCallback( + (event: React.ChangeEvent) => { + const newCriterion = cloneDeep(criterion); + newCriterion.modifier = event.target.value as CriterionModifier; + setCriterion(newCriterion); + }, + [criterion, setCriterion] + ); + + const modifierSelector = useMemo(() => { + if (!modifierOptions || modifierOptions.length === 0) { + return; + } + + return ( + + {modifierOptions.map((c) => ( + + ))} + + ); + }, [modifierOptions, onChangedModifierSelect, criterion.modifier, intl]); + + const valueControl = useMemo(() => { + function onValueChanged(value: CriterionValue) { + const newCriterion = cloneDeep(criterion); + newCriterion.value = value; + setCriterion(newCriterion); + } + + // always show stashID filter + if (criterion instanceof StashIDCriterion) { + return ( + + ); + } + + // Hide the value select if the modifier is "IsNull" or "NotNull" + if ( + criterion.modifier === CriterionModifier.IsNull || + criterion.modifier === CriterionModifier.NotNull + ) { + return; + } + + if (criterion instanceof ILabeledIdCriterion) { + return ( + + ); + } + if (criterion instanceof IHierarchicalLabeledIdCriterion) { + return ( + + ); + } + if ( + options && + !criterionIsHierarchicalLabelValue(criterion.value) && + !criterionIsNumberValue(criterion.value) && + !criterionIsStashIDValue(criterion.value) && + !criterionIsDateValue(criterion.value) && + !criterionIsTimestampValue(criterion.value) && + !Array.isArray(criterion.value) + ) { + // if (!modifierOptions || modifierOptions.length === 0) { + return ( + + ); + // } + + // return ( + // + // ); + } + if (criterion instanceof DurationCriterion) { + return ( + + ); + } + if (criterion instanceof DateCriterion) { + return ( + + ); + } + if (criterion instanceof TimestampCriterion) { + return ( + + ); + } + if (criterion instanceof NumberCriterion) { + return ( + + ); + } + if (criterion instanceof RatingCriterion) { + return ( + + ); + } + if ( + criterion instanceof CountryCriterion && + (criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals) + ) { + return ( + onValueChanged(v)} + /> + ); + } + return ( + + ); + }, [criterion, setCriterion, options]); + + return ( +
+ {modifierSelector} + {valueControl} +
+ ); +}; + +interface ICriterionEditor { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const CriterionEditor: React.FC = ({ + criterion, + setCriterion, +}) => { + const filterControl = useMemo(() => { + if (criterion instanceof BooleanCriterion) { + return ( + + ); + } + + return ( + + ); + }, [criterion, setCriterion]); + + return
{filterControl}
; +}; diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx new file mode 100644 index 00000000000..7097bc1d86b --- /dev/null +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -0,0 +1,340 @@ +import cloneDeep from "lodash-es/cloneDeep"; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Accordion, Button, Card, Modal } from "react-bootstrap"; +import cx from "classnames"; +import { + CriterionValue, + Criterion, + CriterionOption, +} from "src/models/list-filter/criteria/criterion"; +import { makeCriteria } from "src/models/list-filter/criteria/factory"; +import { FormattedMessage, useIntl } from "react-intl"; +import { ConfigurationContext } from "src/hooks/Config"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { getFilterOptions } from "src/models/list-filter/factory"; +import { FilterTags } from "./FilterTags"; +import { CriterionEditor } from "./CriterionEditor"; +import { Icon } from "../Shared/Icon"; +import { + faChevronDown, + faChevronRight, + faTimes, +} from "@fortawesome/free-solid-svg-icons"; +import { useCompare, usePrevious } from "src/hooks/state"; +import { CriterionType } from "src/models/list-filter/types"; + +interface ICriterionList { + criteria: string[]; + currentCriterion?: Criterion; + setCriterion: (c: Criterion) => void; + criterionOptions: CriterionOption[]; + selected?: CriterionOption; + optionSelected: (o?: CriterionOption) => void; + onRemoveCriterion: (c: string) => void; +} + +const CriterionOptionList: React.FC = ({ + criteria, + currentCriterion, + setCriterion, + criterionOptions, + selected, + optionSelected, + onRemoveCriterion, +}) => { + const prevCriterion = usePrevious(currentCriterion); + + const scrolled = useRef(false); + + const type = currentCriterion?.criterionOption.type; + const prevType = prevCriterion?.criterionOption.type; + + const criteriaRefs = useMemo(() => { + const refs: Record> = {}; + criterionOptions.forEach((c) => { + refs[c.type] = React.createRef(); + }); + return refs; + }, [criterionOptions]); + + function onSelect(k: string | null) { + if (!k) { + optionSelected(undefined); + return; + } + const option = criterionOptions.find((c) => c.type === k); + + if (option) { + optionSelected(option); + } + } + + useEffect(() => { + // scrolling to the current criterion doesn't work well when the + // dialog is already open, so limit to when we click on the + // criterion from the external tags + if (!scrolled.current && type && criteriaRefs[type]?.current) { + criteriaRefs[type].current!.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + scrolled.current = true; + } + }, [currentCriterion, criteriaRefs, type]); + + function getReleventCriterion(t: CriterionType) { + if (currentCriterion?.criterionOption.type === t) { + return currentCriterion; + } + + return prevCriterion; + } + + function removeClicked(ev: React.MouseEvent, t: string) { + // needed to prevent the nav item from being selected + ev.stopPropagation(); + ev.preventDefault(); + onRemoveCriterion(t); + } + + return ( + + {criterionOptions.map((c) => ( + + + + + + + {criteria.some((cc) => c.type === cc) && ( + + )} + + + {(type === c.type && currentCriterion) || + (prevType === c.type && prevCriterion) ? ( + + + + ) : ( + + )} + + + ))} + + ); +}; + +interface IEditFilterProps { + filter: ListFilterModel; + editingCriterion?: string; + onApply: (filter: ListFilterModel) => void; + onCancel: () => void; +} + +export const EditFilterDialog: React.FC = ({ + filter, + editingCriterion, + onApply, + onCancel, +}) => { + const intl = useIntl(); + + const { configuration: config } = useContext(ConfigurationContext); + + const [currentFilter, setCurrentFilter] = useState( + cloneDeep(filter) + ); + const [criterion, setCriterion] = useState>(); + + const { criteria } = currentFilter; + + const criteriaList = useMemo(() => { + return criteria.map((c) => c.criterionOption.type); + }, [criteria]); + + const filterOptions = useMemo(() => { + return getFilterOptions(currentFilter.mode); + }, [currentFilter.mode]); + + const criterionOptions = useMemo(() => { + const filteredOptions = filterOptions.criterionOptions.filter((o) => { + return o.type !== "none"; + }); + + filteredOptions.sort((a, b) => { + return intl + .formatMessage({ id: a.messageID }) + .localeCompare(intl.formatMessage({ id: b.messageID })); + }); + + return filteredOptions; + }, [intl, filterOptions.criterionOptions]); + + const optionSelected = useCallback( + (option?: CriterionOption) => { + if (!option) { + setCriterion(undefined); + return; + } + + // find the existing criterion if present + const existing = criteria.find( + (c) => c.criterionOption.type === option.type + ); + if (existing) { + setCriterion(existing); + } else { + const newCriterion = makeCriteria(config, option.type); + setCriterion(newCriterion); + } + }, + [criteria, config] + ); + + const editingCriterionChanged = useCompare(editingCriterion); + + useEffect(() => { + if (editingCriterionChanged && editingCriterion) { + const option = criterionOptions.find((c) => c.type === editingCriterion); + if (option) { + optionSelected(option); + } + } + }, [ + editingCriterion, + criterionOptions, + optionSelected, + editingCriterionChanged, + ]); + + function replaceCriterion(c: Criterion) { + const newFilter = cloneDeep(currentFilter); + + if (!c.isValid()) { + // remove from the filter if present + const newCriteria = criteria.filter((cc) => { + return cc.criterionOption.type !== c.criterionOption.type; + }); + + newFilter.criteria = newCriteria; + } else { + let found = false; + + const newCriteria = criteria.map((cc) => { + if (cc.criterionOption.type === c.criterionOption.type) { + found = true; + return c; + } + + return cc; + }); + + if (!found) { + newCriteria.push(c); + } + + newFilter.criteria = newCriteria; + } + + setCurrentFilter(newFilter); + setCriterion(c); + } + + function removeCriterion(c: Criterion) { + const newFilter = cloneDeep(currentFilter); + + const newCriteria = criteria.filter((cc) => { + return cc.getId() !== c.getId(); + }); + + newFilter.criteria = newCriteria; + + setCurrentFilter(newFilter); + if (criterion?.getId() === c.getId()) { + optionSelected(undefined); + } + } + + function removeCriterionString(c: string) { + const cc = criteria.find((ccc) => ccc.criterionOption.type === c); + if (cc) { + removeCriterion(cc); + } + } + + function onClearAll() { + const newFilter = cloneDeep(currentFilter); + newFilter.criteria = []; + setCurrentFilter(newFilter); + } + + return ( + <> + onCancel()} className="edit-filter-dialog"> + + + + +
+ removeCriterionString(c)} + /> + {criteria.length > 0 && ( +
+ optionSelected(c.criterionOption)} + onRemoveCriterion={(c) => removeCriterion(c)} + onRemoveAll={() => onClearAll()} + /> +
+ )} +
+
+ + + + +
+ + ); +}; diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index f1eb0228351..779fa26be37 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -4,7 +4,7 @@ import { Criterion, CriterionValue, } from "src/models/list-filter/criteria/criterion"; -import { useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; @@ -12,12 +12,14 @@ interface IFilterTagsProps { criteria: Criterion[]; onEditCriterion: (c: Criterion) => void; onRemoveCriterion: (c: Criterion) => void; + onRemoveAll: () => void; } export const FilterTags: React.FC = ({ criteria, onEditCriterion, onRemoveCriterion, + onRemoveAll, }) => { const intl = useIntl(); @@ -55,9 +57,26 @@ export const FilterTags: React.FC = ({ )); } + function maybeRenderClearAll() { + if (criteria.length < 3) { + return; + } + + return ( + + ); + } + return ( -
+
{renderFilterTags()} + {maybeRenderClearAll()}
); }; diff --git a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx new file mode 100644 index 00000000000..0a04a4fc657 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx @@ -0,0 +1,45 @@ +import cloneDeep from "lodash-es/cloneDeep"; +import React from "react"; +import { Form } from "react-bootstrap"; +import { BooleanCriterion } from "src/models/list-filter/criteria/criterion"; +import { FormattedMessage } from "react-intl"; + +interface IBooleanFilter { + criterion: BooleanCriterion; + setCriterion: (c: BooleanCriterion) => void; +} + +export const BooleanFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: boolean) { + const c = cloneDeep(criterion); + if ((v && c.value === "true") || (!v && c.value === "false")) { + c.value = ""; + } else { + c.value = v ? "true" : "false"; + } + + setCriterion(c); + } + + return ( +
+ onSelect(true)} + checked={criterion.value === "true"} + type="checkbox" + label={} + /> + onSelect(false)} + checked={criterion.value === "false"} + type="checkbox" + label={} + /> +
+ ); +}; diff --git a/ui/v2.5/src/components/List/Filters/DateFilter.tsx b/ui/v2.5/src/components/List/Filters/DateFilter.tsx index 9235b8f04f1..44e38df36dc 100644 --- a/ui/v2.5/src/components/List/Filters/DateFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/DateFilter.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; @@ -16,18 +16,17 @@ export const DateFilter: React.FC = ({ }) => { const intl = useIntl(); - const valueStage = useRef(criterion.value); + const { value } = criterion; function onChanged( event: React.ChangeEvent, property: "value" | "value2" ) { - const { value } = event.target; - valueStage.current[property] = value; - } + const newValue = event.target.value; + const valueCopy = { ...value }; - function onBlurInput() { - onValueChanged(valueStage.current); + valueCopy[property] = newValue; + onValueChanged(valueCopy); } let equalsControl: JSX.Element | null = null; @@ -43,8 +42,7 @@ export const DateFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - onBlur={onBlurInput} - defaultValue={criterion.value?.value ?? ""} + value={value?.value ?? ""} placeholder={ intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD)" } @@ -67,8 +65,7 @@ export const DateFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - onBlur={onBlurInput} - defaultValue={criterion.value?.value ?? ""} + value={value?.value ?? ""} placeholder={ intl.formatMessage({ id: "criterion.greater_than" }) + " (YYYY-MM-DD)" @@ -97,11 +94,10 @@ export const DateFilter: React.FC = ({ : "value2" ) } - onBlur={onBlurInput} - defaultValue={ + value={ (criterion.modifier === CriterionModifier.LessThan - ? criterion.value?.value - : criterion.value?.value2) ?? "" + ? value?.value + : value?.value2) ?? "" } placeholder={ intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD)" diff --git a/ui/v2.5/src/components/List/Filters/FilterButton.tsx b/ui/v2.5/src/components/List/Filters/FilterButton.tsx new file mode 100644 index 00000000000..ac41fe9852f --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/FilterButton.tsx @@ -0,0 +1,28 @@ +import React, { useMemo } from "react"; +import { Badge, Button } from "react-bootstrap"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { faFilter } from "@fortawesome/free-solid-svg-icons"; +import { Icon } from "src/components/Shared/Icon"; + +interface IFilterButtonProps { + filter: ListFilterModel; + onClick: () => void; +} + +export const FilterButton: React.FC = ({ + filter, + onClick, +}) => { + const count = useMemo(() => filter.count(), [filter]); + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx index 8ea26bae5d7..bb262583881 100644 --- a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx @@ -80,6 +80,7 @@ export const HierarchicalLabelValueFilter: React.FC< isMulti onSelect={onSelectionChanged} ids={criterion.value.items.map((labeled) => labeled.id)} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/List/Filters/InputFilter.tsx b/ui/v2.5/src/components/List/Filters/InputFilter.tsx index 95e6ce15dff..46ee7b6b012 100644 --- a/ui/v2.5/src/components/List/Filters/InputFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/InputFilter.tsx @@ -19,13 +19,15 @@ export const InputFilter: React.FC = ({ } return ( - - - + <> + + + + ); }; diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index b859f83453b..f06e5c21bdc 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -42,6 +42,7 @@ export const LabeledIdFilter: React.FC = ({ isMulti onSelect={onSelectionChanged} ids={criterion.value.map((labeled) => labeled.id)} + menuPortalTarget={document.body} /> ); diff --git a/ui/v2.5/src/components/List/Filters/NumberFilter.tsx b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx index c25ff98148f..7aa574a2e21 100644 --- a/ui/v2.5/src/components/List/Filters/NumberFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx @@ -3,10 +3,10 @@ import { Form } from "react-bootstrap"; import { useIntl } 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 { NumberCriterion } from "../../../models/list-filter/criteria/criterion"; interface IDurationFilterProps { - criterion: Criterion; + criterion: NumberCriterion; onValueChanged: (value: INumberValue) => void; } @@ -16,12 +16,14 @@ export const NumberFilter: React.FC = ({ }) => { const intl = useIntl(); + const { value } = criterion; + function onChanged( event: React.ChangeEvent, property: "value" | "value2" ) { const numericValue = parseInt(event.target.value, 10); - const valueCopy = { ...criterion.value }; + const valueCopy = { ...value }; valueCopy[property] = !Number.isNaN(numericValue) ? numericValue : 0; onValueChanged(valueCopy); @@ -40,7 +42,7 @@ export const NumberFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - value={criterion.value?.value ?? ""} + value={value?.value ?? ""} placeholder={intl.formatMessage({ id: "criterion.value" })} /> @@ -61,7 +63,7 @@ export const NumberFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - value={criterion.value?.value ?? ""} + value={value?.value ?? ""} placeholder={intl.formatMessage({ id: "criterion.greater_than" })} /> @@ -89,8 +91,8 @@ export const NumberFilter: React.FC = ({ } value={ (criterion.modifier === CriterionModifier.LessThan - ? criterion.value?.value - : criterion.value?.value2) ?? "" + ? value?.value + : value?.value2) ?? "" } placeholder={intl.formatMessage({ id: "criterion.less_than" })} /> diff --git a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx index c0d6baead68..2f6f40bdc37 100644 --- a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { Form } from "react-bootstrap"; import { Criterion, @@ -18,16 +18,13 @@ export const OptionsFilter: React.FC = ({ onValueChanged(event.target.value); } - const options = criterion.criterionOption.options ?? []; + const options = useMemo(() => { + const ret = criterion.criterionOption.options?.slice() ?? []; - if ( - options && - (criterion.value === undefined || - criterion.value === "" || - typeof criterion.value === "number") - ) { - onValueChanged(options[0].toString()); - } + ret.unshift(""); + + return ret; + }, [criterion.criterionOption.options]); return ( @@ -39,7 +36,7 @@ export const OptionsFilter: React.FC = ({ > {options.map((c) => ( ))} diff --git a/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx new file mode 100644 index 00000000000..b84cf8bd129 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx @@ -0,0 +1,45 @@ +import cloneDeep from "lodash-es/cloneDeep"; +import React from "react"; +import { Form } from "react-bootstrap"; +import { + CriterionValue, + Criterion, +} from "src/models/list-filter/criteria/criterion"; + +interface IOptionsListFilter { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const OptionsListFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: string) { + const c = cloneDeep(criterion); + if (c.value === v) { + c.value = ""; + } else { + c.value = v; + } + + setCriterion(c); + } + + const { options } = criterion.criterionOption; + + return ( +
+ {options?.map((o) => ( + onSelect(o.toString())} + checked={criterion.value === o.toString()} + type="checkbox" + label={o.toString()} + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/List/Filters/StashIDFilter.tsx b/ui/v2.5/src/components/List/Filters/StashIDFilter.tsx index 096f573b7eb..f28961bf53e 100644 --- a/ui/v2.5/src/components/List/Filters/StashIDFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StashIDFilter.tsx @@ -15,6 +15,7 @@ export const StashIDFilter: React.FC = ({ onValueChanged, }) => { const intl = useIntl(); + const { value } = criterion; function onEndpointChanged(event: React.ChangeEvent) { onValueChanged({ @@ -35,8 +36,8 @@ export const StashIDFilter: React.FC = ({ @@ -45,8 +46,8 @@ export const StashIDFilter: React.FC = ({ diff --git a/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx index de6eefb72d7..eaf0351d8e1 100644 --- a/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; @@ -16,18 +16,17 @@ export const TimestampFilter: React.FC = ({ }) => { const intl = useIntl(); - const valueStage = useRef(criterion.value); + const { value } = criterion; function onChanged( event: React.ChangeEvent, property: "value" | "value2" ) { - const { value } = event.target; - valueStage.current[property] = value; - } + const newValue = event.target.value; + const valueCopy = { ...value }; - function onBlurInput() { - onValueChanged(valueStage.current); + valueCopy[property] = newValue; + onValueChanged(valueCopy); } let equalsControl: JSX.Element | null = null; @@ -43,11 +42,10 @@ export const TimestampFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - onBlur={onBlurInput} - defaultValue={criterion.value?.value ?? ""} + value={value?.value ?? ""} placeholder={ intl.formatMessage({ id: "criterion.value" }) + - " (YYYY-MM-DD HH-MM)" + " (YYYY-MM-DD HH:MM)" } />
@@ -68,11 +66,10 @@ export const TimestampFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - onBlur={onBlurInput} - defaultValue={criterion.value?.value ?? ""} + value={value?.value ?? ""} placeholder={ intl.formatMessage({ id: "criterion.greater_than" }) + - " (YYYY-MM-DD HH-MM)" + " (YYYY-MM-DD HH:MM)" } /> @@ -98,15 +95,14 @@ export const TimestampFilter: React.FC = ({ : "value2" ) } - onBlur={onBlurInput} - defaultValue={ + value={ (criterion.modifier === CriterionModifier.LessThan - ? criterion.value?.value - : criterion.value?.value2) ?? "" + ? value?.value + : value?.value2) ?? "" } placeholder={ intl.formatMessage({ id: "criterion.less_than" }) + - " (YYYY-MM-DD HH-MM)" + " (YYYY-MM-DD HH:MM)" } /> diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 386d765c677..a4ef2a7acaa 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -25,7 +25,7 @@ import { ConfigurationContext } from "src/hooks/Config"; import { getFilterOptions } from "src/models/list-filter/factory"; import { useFindDefaultFilter } from "src/core/StashService"; import { Pagination, PaginationIndex } from "./Pagination"; -import { AddFilterDialog } from "./AddFilterDialog"; +import { EditFilterDialog } from "src/components/List/EditFilterDialog"; import { ListFilter } from "./ListFilter"; import { FilterTags } from "./FilterTags"; import { ListViewOptions } from "./ListViewOptions"; @@ -140,7 +140,6 @@ export function makeItemList({ onChangePage: _onChangePage, updateFilter, persistState, - filterDialog, zoomable, selectable, otherOperations, @@ -154,9 +153,10 @@ export function makeItemList({ const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastClickedId, setLastClickedId] = useState(); - const [editingCriterion, setEditingCriterion] = - useState>(); - const [newCriterion, setNewCriterion] = useState(false); + const [editingCriterion, setEditingCriterion] = useState< + string | undefined + >(); + const [showEditFilter, setShowEditFilter] = useState(false); const result = useResult(filter); const [totalCount, setTotalCount] = useState(0); @@ -193,7 +193,7 @@ export function makeItemList({ // set up hotkeys useEffect(() => { - Mousetrap.bind("f", () => setNewCriterion(true)); + Mousetrap.bind("f", () => setShowEditFilter(true)); return () => { Mousetrap.unbind("f"); @@ -432,40 +432,6 @@ export function makeItemList({ updateFilter(newFilter); } - function onAddCriterion( - criterion: Criterion, - oldId?: string - ) { - const newFilter = cloneDeep(filter); - - // Find if we are editing an existing criteria, then modify that. Or create a new one. - const existingIndex = newFilter.criteria.findIndex((c) => { - // If we modified an existing criterion, then look for the old id. - const id = oldId || criterion.getId(); - return c.getId() === id; - }); - if (existingIndex === -1) { - newFilter.criteria.push(criterion); - } else { - newFilter.criteria[existingIndex] = criterion; - } - - // Remove duplicate modifiers - newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => { - return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos; - }); - - newFilter.currentPage = 1; - updateFilter(newFilter); - setEditingCriterion(undefined); - setNewCriterion(false); - } - - function onCancelAddCriterion() { - setEditingCriterion(undefined); - setNewCriterion(false); - } - function onRemoveCriterion(removedCriterion: Criterion) { const newFilter = cloneDeep(filter); newFilter.criteria = newFilter.criteria.filter( @@ -475,10 +441,22 @@ export function makeItemList({ updateFilter(newFilter); } - function updateCriteria(c: Criterion[]) { + function onClearAllCriteria() { const newFilter = cloneDeep(filter); - newFilter.criteria = c.slice(); - setNewCriterion(false); + newFilter.criteria = []; + newFilter.currentPage = 1; + updateFilter(newFilter); + } + + function onApplyEditFilter(f: ListFilterModel) { + setShowEditFilter(false); + setEditingCriterion(undefined); + updateFilter(f); + } + + function onCancelEditFilter() { + setShowEditFilter(false); + setEditingCriterion(undefined); } return ( @@ -488,8 +466,7 @@ export function makeItemList({ onFilterUpdate={updateFilter} filter={filter} filterOptions={filterOptions} - openFilterDialog={() => setNewCriterion(true)} - filterDialogOpen={newCriterion} + openFilterDialog={() => setShowEditFilter(true)} persistState={persistState} /> ({ setEditingCriterion(c)} + onEditCriterion={(c) => setEditingCriterion(c.criterionOption.type)} onRemoveCriterion={onRemoveCriterion} + onRemoveAll={() => onClearAllCriteria()} /> - {(newCriterion || editingCriterion) && !filterDialog && ( - )} - {newCriterion && - filterDialog && - filterDialog(filter.criteria, (c) => updateCriteria(c))} {isEditDialogOpen && renderEditDialog && renderEditDialog(getSelectedData(items, selectedIds), (applied) => diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 830947b87d4..93b227828ad 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -34,17 +34,16 @@ import { faCaretDown, faCaretUp, faCheck, - faFilter, faRandom, faTimes, } from "@fortawesome/free-solid-svg-icons"; +import { FilterButton } from "./Filters/FilterButton"; import { useDebounce } from "src/hooks/debounce"; interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; filter: ListFilterModel; filterOptions: ListFilterOptions; - filterDialogOpen?: boolean; persistState?: PersistanceLevel; openFilterDialog: () => void; } @@ -55,7 +54,6 @@ export const ListFilter: React.FC = ({ onFilterUpdate, filter, filterOptions, - filterDialogOpen, openFilterDialog, persistState, }) => { @@ -289,13 +287,7 @@ export const ListFilter: React.FC = ({ } > - + openFilterDialog()} filter={filter} /> diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 99933638b4c..c8fcb4bc4fc 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -116,3 +116,83 @@ input[type="range"].zoom-slider { .rating-filter .and-divider { margin-left: 0.5em; } + +.edit-filter-dialog { + .modal-body { + padding-left: 0; + padding-right: 0; + } + + .filter-tags { + border-top: 1px solid rgb(16 22 26 / 40%); + padding: 1rem 1rem 0 1rem; + } + + .criterion-list { + flex-direction: column; + flex-wrap: nowrap; + max-height: 550px; + overflow-y: auto; + + .card { + border: 1px solid rgb(16 22 26 / 40%); + box-shadow: none; + margin: 0 0 -1px; + padding: 0; + + .collapse-icon { + margin-left: 0; + } + + .card-header { + cursor: pointer; + display: flex; + justify-content: space-between; + } + } + + .remove-criterion-button { + border: 0; + color: $danger; + padding-bottom: 0; + padding-top: 0; + } + } + + .edit-filter-right { + display: flex; + flex-direction: column; + justify-content: space-between; + padding-left: 1rem; + padding-right: 1rem; + width: 100%; + } +} + +.modifier-selector { + margin-bottom: 1rem; + + // to accommodate for caret + padding-right: 2rem; +} + +.filter-tags .clear-all-button { + color: $text-color; + // to match filter pills + line-height: 16px; + padding: 0; +} + +.filter-button { + .fa-icon { + margin: 0; + } + + .badge { + position: absolute; + right: -3px; + + // button group has a z-index of 1 + z-index: 2; + } +} diff --git a/ui/v2.5/src/docs/en/Changelog/v0200.md b/ui/v2.5/src/docs/en/Changelog/v0200.md index 294e315496e..de442508de9 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0200.md +++ b/ui/v2.5/src/docs/en/Changelog/v0200.md @@ -14,6 +14,7 @@ * Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369)) ### 🎨 Improvements +* Overhauled filtering interface to allow setting filter criteria from a single dialog. ([#3515](https://github.com/stashapp/stash/pull/3515)) * Removed upper limit on page size. ([#3544](https://github.com/stashapp/stash/pull/3544)) * Anonymise task now obfuscates Marker titles. ([#3542](https://github.com/stashapp/stash/pull/3542)) * Improved Images wall view layout and added Interface settings to adjust the layout. ([#3511](https://github.com/stashapp/stash/pull/3511)) diff --git a/ui/v2.5/src/hooks/state.ts b/ui/v2.5/src/hooks/state.ts index 00ee32a912e..9c10523bd6f 100644 --- a/ui/v2.5/src/hooks/state.ts +++ b/ui/v2.5/src/hooks/state.ts @@ -32,3 +32,18 @@ export function useInitialState( return [value, setValue, setInitialValue]; } + +// useCompare is a hook that returns true if the value has changed since the last render. +export function useCompare(val: T) { + const prevVal = usePrevious(val); + return prevVal !== val; +} + +// usePrevious is a hook that returns the previous value of a variable. +export function usePrevious(value: T) { + const ref = React.useRef(); + React.useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index b9af77aca10..5e405368434 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -286,11 +286,15 @@ div.react-select__control { } } +div.react-select__menu-portal { + z-index: 1600; +} + div.react-select__menu, div.dropdown-menu { background-color: $secondary; color: $text-color; - z-index: 16; + z-index: 1600; .react-select__option, .dropdown-item { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index f284b230573..34120dd0605 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1039,7 +1039,7 @@ "scenes": "Scenes", "scenes_updated_at": "Scene Updated At", "search_filter": { - "add_filter": "Add Filter", + "edit_filter": "Edit Filter", "name": "Filter", "saved_filters": "Saved filters", "update_filter": "Update Filter" 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 60d245f62d5..a6563834601 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -70,6 +70,10 @@ export abstract class Criterion { this._value = newValue; } + public isValid(): boolean { + return true; + } + public abstract getLabelValue(intl: IntlShape): string; constructor(type: CriterionOption, value: V) { @@ -227,6 +231,14 @@ export class StringCriterion extends Criterion { public getLabelValue(_intl: IntlShape) { return this.value; } + + public isValid(): boolean { + return ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.value.length > 0 + ); + } } export class MandatoryStringCriterionOption extends CriterionOption { @@ -284,6 +296,10 @@ export class BooleanCriterion extends StringCriterion { protected toCriterionInput(): boolean { return this.value === "true"; } + + public isValid() { + return this.value === "true" || this.value === "false"; + } } export function createBooleanCriterionOption( @@ -375,7 +391,7 @@ export class NumberCriterion extends Criterion { protected toCriterionInput(): IntCriterionInput { return { modifier: this.modifier, - value: this.value.value, + value: this.value.value ?? 0, value2: this.value.value2, }; } @@ -392,8 +408,32 @@ export class NumberCriterion extends Criterion { } } + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull + ) { + return true; + } + + const { value, value2 } = this.value; + if (value === undefined) { + return false; + } + + if ( + value2 === undefined && + (this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween) + ) { + return false; + } + + return true; + } + constructor(type: CriterionOption) { - super(type, { value: 0, value2: undefined }); + super(type, { value: undefined, value2: undefined }); } } @@ -439,6 +479,17 @@ export class ILabeledIdCriterion extends Criterion { }; } + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull + ) { + return true; + } + + return this.value.length > 0; + } + constructor(type: CriterionOption) { super(type, []); } @@ -463,6 +514,17 @@ export class IHierarchicalLabeledIdCriterion extends Criterion 0 ? this.value.depth : "all"})`; } + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull + ) { + return true; + } + + return this.value.items.length > 0; + } + constructor(type: CriterionOption) { const value: IHierarchicalLabelValue = { items: [], @@ -502,13 +564,13 @@ export function createMandatoryNumberCriterionOption( export class DurationCriterion extends Criterion { constructor(type: CriterionOption) { - super(type, { value: 0, value2: undefined }); + super(type, { value: undefined, value2: undefined }); } protected toCriterionInput(): IntCriterionInput { return { modifier: this.modifier, - value: this.value.value, + value: this.value.value ?? 0, value2: this.value.value2, }; } @@ -517,15 +579,39 @@ export class DurationCriterion extends Criterion { return this.modifier === CriterionModifier.Between || this.modifier === CriterionModifier.NotBetween ? `${DurationUtils.secondsToString( - this.value.value + this.value.value ?? 0 )} ${DurationUtils.secondsToString(this.value.value2 ?? 0)}` : this.modifier === CriterionModifier.GreaterThan || this.modifier === CriterionModifier.LessThan || this.modifier === CriterionModifier.Equals || this.modifier === CriterionModifier.NotEquals - ? DurationUtils.secondsToString(this.value.value) + ? DurationUtils.secondsToString(this.value.value ?? 0) : "?"; } + + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull + ) { + return true; + } + + const { value, value2 } = this.value; + if (value === undefined) { + return false; + } + + if ( + value2 === undefined && + (this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween) + ) { + return false; + } + + return true; + } } export class PhashDuplicateCriterion extends StringCriterion { @@ -592,6 +678,30 @@ export class DateCriterion extends Criterion { : `${value}`; } + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull + ) { + return true; + } + + const { value, value2 } = this.value; + if (!value) { + return false; + } + + if ( + !value2 && + (this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween) + ) { + return false; + } + + return true; + } + constructor(type: CriterionOption) { super(type, { value: "", value2: undefined }); } @@ -662,6 +772,30 @@ export class TimestampCriterion extends Criterion { return ""; } + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull + ) { + return true; + } + + const { value, value2 } = this.value; + if (!value) { + return false; + } + + if ( + !value2 && + (this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween) + ) { + return false; + } + + return true; + } + constructor(type: CriterionOption) { super(type, { value: "", value2: undefined }); } 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 ffacdaf3f34..56265fb9d84 100644 --- a/ui/v2.5/src/models/list-filter/criteria/rating.ts +++ b/ui/v2.5/src/models/list-filter/criteria/rating.ts @@ -31,7 +31,7 @@ export class RatingCriterion extends Criterion { protected toCriterionInput(): IntCriterionInput { return { modifier: this.modifier, - value: this.value.value, + value: this.value.value ?? 0, value2: this.value.value2, }; } 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 6467c50ea4d..c4db4d59633 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 @@ -103,4 +103,12 @@ export class StashIDCriterion extends Criterion { } return JSON.stringify(encodedCriterion); } + + public isValid(): boolean { + return ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.value.stashID.length > 0 + ); + } } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index a00f2716a07..6aff929cadd 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -75,6 +75,16 @@ export class ListFilterModel { return Object.assign(new ListFilterModel(this.mode, this.config), this); } + // returns the number of filters applied + public count() { + const count = this.criteria.length; + if (this.searchTerm) { + return count + 1; + } + + return count; + } + public configureFromDecodedParams(params: IDecodedParams) { if (params.perPage !== undefined) { this.itemsPerPage = params.perPage; diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 3aa192f3afa..3dd9e589cb0 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -24,7 +24,7 @@ export interface IHierarchicalLabelValue { } export interface INumberValue { - value: number; + value: number | undefined; value2: number | undefined; }