From 7cb3d05535f209dee97e46fdc4d9f794331135dd Mon Sep 17 00:00:00 2001 From: gitgiggety <79809426+gitgiggety@users.noreply.github.com> Date: Thu, 12 Aug 2021 02:24:16 +0200 Subject: [PATCH] Add (not) between modifiers for number criterion (#1559) * Add (not) between modifiers for number criterion * Extract list filters into dedicated components Extract the filters from the AddFiltersDialog into custom components. This allows for further refactorring where components will be bound to criterions. * Add placeholders to number and duration criterions * Add backwards compatibility for saved filters Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 5 + pkg/sqlite/filter.go | 17 +- pkg/sqlite/performer.go | 43 +--- pkg/sqlite/query.go | 13 +- pkg/sqlite/scene.go | 32 +-- pkg/sqlite/sql.go | 35 +++- pkg/sqlite/tag.go | 35 +--- .../src/components/Changelog/versions/v090.md | 1 + .../src/components/List/AddFilterDialog.tsx | 186 ++++-------------- .../List/Filters/DurationFilter.tsx | 94 +++++++++ .../Filters/HierarchicalLabelValueFilter.tsx | 91 +++++++++ .../components/List/Filters/InputFilter.tsx | 31 +++ .../List/Filters/LabeledIdFilter.tsx | 46 +++++ .../components/List/Filters/NumberFilter.tsx | 114 +++++++++++ .../components/List/Filters/OptionsFilter.tsx | 48 +++++ .../src/components/Shared/DurationInput.tsx | 9 +- ui/v2.5/src/locales/en-GB.json | 9 +- .../models/list-filter/criteria/criterion.ts | 80 +++++++- ui/v2.5/src/models/list-filter/types.ts | 12 ++ 19 files changed, 623 insertions(+), 278 deletions(-) create mode 100644 ui/v2.5/src/components/List/Filters/DurationFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/InputFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/NumberFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/OptionsFilter.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 06187a81f..989b95863 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -327,6 +327,10 @@ enum CriterionModifier { MATCHES_REGEX, """NOT MATCHES REGEX""" NOT_MATCHES_REGEX, + """>= AND <=""" + BETWEEN, + """< OR >""" + NOT_BETWEEN, } input StringCriterionInput { @@ -336,6 +340,7 @@ input StringCriterionInput { input IntCriterionInput { value: Int! + value2: Int modifier: CriterionModifier! } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 1f1f6c13d..cb8e56e97 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -368,13 +368,8 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite func intCriterionHandler(c *models.IntCriterionInput, column string) criterionHandlerFunc { return func(f *filterBuilder) { if c != nil { - clause, count := getIntCriterionWhereClause(column, *c) - - if count == 1 { - f.addWhere(clause, c.Value) - } else { - f.addWhere(clause) - } + clause, args := getIntCriterionWhereClause(column, *c) + f.addWhere(clause, args...) } } } @@ -495,13 +490,9 @@ type countCriterionHandlerBuilder struct { func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { if criterion != nil { - clause, count := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) + clause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) - if count == 1 { - f.addWhere(clause, criterion.Value) - } else { - f.addWhere(clause) - } + f.addWhere(clause, args...) } } } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index fbe33eb62..4c7c4697a 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -3,7 +3,6 @@ package sqlite import ( "database/sql" "fmt" - "strconv" "strings" "github.com/stashapp/stash/pkg/models" @@ -356,25 +355,8 @@ func performerIsMissingCriterionHandler(qb *performerQueryBuilder, isMissing *st func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { return func(f *filterBuilder) { if year != nil && year.Modifier.IsValid() { - yearStr := strconv.Itoa(year.Value) - startOfYear := yearStr + "-01-01" - endOfYear := yearStr + "-12-31" - - switch year.Modifier { - case models.CriterionModifierEquals: - // between yyyy-01-01 and yyyy-12-31 - f.addWhere(col+" >= ?", startOfYear) - f.addWhere(col+" <= ?", endOfYear) - case models.CriterionModifierNotEquals: - // outside of yyyy-01-01 to yyyy-12-31 - f.addWhere(col+" < ? OR "+col+" > ?", startOfYear, endOfYear) - case models.CriterionModifierGreaterThan: - // > yyyy-12-31 - f.addWhere(col+" > ?", endOfYear) - case models.CriterionModifierLessThan: - // < yyyy-01-01 - f.addWhere(col+" < ?", startOfYear) - } + clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year) + f.addWhere(clause, args...) } } } @@ -382,22 +364,11 @@ func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) crit func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { if age != nil && age.Modifier.IsValid() { - var op string - - switch age.Modifier { - case models.CriterionModifierEquals: - op = "==" - case models.CriterionModifierNotEquals: - op = "!=" - case models.CriterionModifierGreaterThan: - op = ">" - case models.CriterionModifierLessThan: - op = "<" - } - - if op != "" { - f.addWhere("cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int) "+op+" ?", age.Value) - } + clause, args := getIntCriterionWhereClause( + "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", + *age, + ) + f.addWhere(clause, args...) } } } diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 60b594f14..2d31ee583 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -135,11 +135,9 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) { func (qb *queryBuilder) handleIntCriterionInput(c *models.IntCriterionInput, column string) { if c != nil { - clause, count := getIntCriterionWhereClause(column, *c) + clause, args := getIntCriterionWhereClause(column, *c) qb.addWhere(clause) - if count == 1 { - qb.addArg(c.Value) - } + qb.addArg(args...) } } @@ -192,12 +190,9 @@ func (qb *queryBuilder) handleStringCriterionInput(c *models.StringCriterionInpu func (qb *queryBuilder) handleCountCriterion(countFilter *models.IntCriterionInput, primaryTable, joinTable, primaryFK string) { if countFilter != nil { - clause, count := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter) - - if count == 1 { - qb.addArg(countFilter.Value) - } + clause, args := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter) qb.addWhere(clause) + qb.addArg(args...) } } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 2c34936a0..fbbaaeb3c 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -461,40 +461,12 @@ func phashCriterionHandler(phashFilter *models.StringCriterionInput) criterionHa func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc { return func(f *filterBuilder) { if durationFilter != nil { - clause, thisArgs := getDurationWhereClause(*durationFilter, column) - f.addWhere(clause, thisArgs...) + clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) + f.addWhere(clause, args...) } } } -func getDurationWhereClause(durationFilter models.IntCriterionInput, column string) (string, []interface{}) { - // special case for duration. We accept duration as seconds as int but the - // field is floating point. Change the equals filter to return a range - // between x and x + 1 - // likewise, not equals needs to be duration < x OR duration >= x - var clause string - args := []interface{}{} - - value := durationFilter.Value - if durationFilter.Modifier == models.CriterionModifierEquals { - clause = fmt.Sprintf("%[1]s >= ? AND %[1]s < ?", column) - args = append(args, value) - args = append(args, value+1) - } else if durationFilter.Modifier == models.CriterionModifierNotEquals { - clause = fmt.Sprintf("(%[1]s < ? OR %[1]s >= ?)", column) - args = append(args, value) - args = append(args, value+1) - } else { - var count int - clause, count = getIntCriterionWhereClause(column, durationFilter) - if count == 1 { - args = append(args, value) - } - } - - return clause, args -} - func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string) criterionHandlerFunc { return func(f *filterBuilder) { if resolution != nil && resolution.Value.IsValid() { diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index b683e1a07..b3bcdd514 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -160,7 +160,7 @@ func getCriterionModifierBinding(criterionModifier models.CriterionModifier, val } if modifier := criterionModifier.String(); criterionModifier.IsValid() { switch modifier { - case "EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN", "IS_NULL", "NOT_NULL": + case "EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN", "IS_NULL", "NOT_NULL", "BETWEEN", "NOT_BETWEEN": return getSimpleCriterionClause(criterionModifier, "?") case "INCLUDES": return "IN " + getInBinding(length), length // TODO? @@ -189,6 +189,10 @@ func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs st return "IS NULL", 0 case "NOT_NULL": return "IS NOT NULL", 0 + case "BETWEEN": + return "BETWEEN (" + rhs + ") AND (" + rhs + ")", 2 + case "NOT_BETWEEN": + return "NOT BETWEEN (" + rhs + ") AND (" + rhs + ")", 2 default: logger.Errorf("todo") return "= ?", 1 // TODO @@ -198,9 +202,30 @@ func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs st return "= ?", 1 // TODO } -func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, int) { - binding, count := getCriterionModifierBinding(input.Modifier, input.Value) - return column + " " + binding, count +func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) { + binding, _ := getSimpleCriterionClause(input.Modifier, "?") + var args []interface{} + + switch input.Modifier { + case "EQUALS", "NOT_EQUALS": + args = []interface{}{input.Value} + break + case "LESS_THAN": + args = []interface{}{input.Value} + break + case "GREATER_THAN": + args = []interface{}{input.Value} + break + case "BETWEEN", "NOT_BETWEEN": + upper := 0 + if input.Value2 != nil { + upper = *input.Value2 + } + args = []interface{}{input.Value, upper} + break + } + + return column + " " + binding, args } // returns where clause and having clause @@ -226,7 +251,7 @@ func getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, f return whereClause, havingClause } -func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, int) { +func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, []interface{}) { lhs := fmt.Sprintf("(SELECT COUNT(*) FROM %s s WHERE s.%s = %s.id)", joinTable, primaryFK, primaryTable) return getIntCriterionWhereClause(lhs, criterion) } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index bc6aac9c8..ca97e5460 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -359,12 +359,7 @@ func tagSceneCountCriterionHandler(qb *tagQueryBuilder, sceneCount *models.IntCr return func(f *filterBuilder) { if sceneCount != nil { f.addJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id") - clause, count := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) - - args := []interface{}{} - if count == 1 { - args = append(args, sceneCount.Value) - } + clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) f.addHaving(clause, args...) } @@ -375,12 +370,7 @@ func tagImageCountCriterionHandler(qb *tagQueryBuilder, imageCount *models.IntCr return func(f *filterBuilder) { if imageCount != nil { f.addJoin("images_tags", "", "images_tags.tag_id = tags.id") - clause, count := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) - - args := []interface{}{} - if count == 1 { - args = append(args, imageCount.Value) - } + clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) f.addHaving(clause, args...) } @@ -391,12 +381,7 @@ func tagGalleryCountCriterionHandler(qb *tagQueryBuilder, galleryCount *models.I return func(f *filterBuilder) { if galleryCount != nil { f.addJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id") - clause, count := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) - - args := []interface{}{} - if count == 1 { - args = append(args, galleryCount.Value) - } + clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) f.addHaving(clause, args...) } @@ -407,12 +392,7 @@ func tagPerformerCountCriterionHandler(qb *tagQueryBuilder, performerCount *mode return func(f *filterBuilder) { if performerCount != nil { f.addJoin("performers_tags", "", "performers_tags.tag_id = tags.id") - clause, count := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) - - args := []interface{}{} - if count == 1 { - args = append(args, performerCount.Value) - } + clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) f.addHaving(clause, args...) } @@ -424,12 +404,7 @@ func tagMarkerCountCriterionHandler(qb *tagQueryBuilder, markerCount *models.Int if markerCount != nil { f.addJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id") f.addJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id") - clause, count := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) - - args := []interface{}{} - if count == 1 { - args = append(args, markerCount.Value) - } + clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) f.addHaving(clause, args...) } diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md index b4df3221f..6564451ba 100644 --- a/ui/v2.5/src/components/Changelog/versions/v090.md +++ b/ui/v2.5/src/components/Changelog/versions/v090.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added between/not between modifiers for number criteria. ([#1559](https://github.com/stashapp/stash/pull/1559)) * Support excluding tag patterns when scraping. ([#1617](https://github.com/stashapp/stash/pull/1617)) * Support setting a custom directory for default performer images. ([#1489](https://github.com/stashapp/stash/pull/1489)) * Added filtering and sorting on scene marker count for tags. ([#1603](https://github.com/stashapp/stash/pull/1603)) diff --git a/ui/v2.5/src/components/List/AddFilterDialog.tsx b/ui/v2.5/src/components/List/AddFilterDialog.tsx index c58c1a276..d67e523b9 100644 --- a/ui/v2.5/src/components/List/AddFilterDialog.tsx +++ b/ui/v2.5/src/components/List/AddFilterDialog.tsx @@ -1,13 +1,14 @@ import _ from "lodash"; import React, { useEffect, useRef, useState } from "react"; import { Button, Form, Modal } from "react-bootstrap"; -import { FilterSelect, DurationInput } from "src/components/Shared"; import { CriterionModifier } from "src/core/generated-graphql"; import { DurationCriterion, CriterionValue, Criterion, IHierarchicalLabeledIdCriterion, + NumberCriterion, + ILabeledIdCriterion, } from "src/models/list-filter/criteria/criterion"; import { NoneCriterion, @@ -15,11 +16,18 @@ import { } 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 { defineMessages, FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { criterionIsHierarchicalLabelValue, + criterionIsNumberValue, 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"; interface IAddFilterProps { onAddCriterion: ( @@ -48,13 +56,6 @@ export const AddFilterDialog: React.FC = ({ const intl = useIntl(); - const messages = defineMessages({ - studio_depth: { - id: "studio_depth", - defaultMessage: "Levels (empty for all)", - }, - }); - // Configure if we are editing an existing criterion useEffect(() => { if (!editingCriterion) { @@ -64,6 +65,10 @@ export const AddFilterDialog: React.FC = ({ } }, [editingCriterion]); + useEffect(() => { + valueStage.current = criterion.value; + }, [criterion]); + function onChangedCriteriaType(event: React.ChangeEvent) { const newCriterionType = event.target.value as CriterionType; const newCriterion = makeCriteria(newCriterionType); @@ -78,41 +83,13 @@ export const AddFilterDialog: React.FC = ({ setCriterion(newCriterion); } - function onChangedSingleSelect(event: React.ChangeEvent) { + function onValueChanged(value: CriterionValue) { const newCriterion = _.cloneDeep(criterion); - newCriterion.value = event.target.value; - setCriterion(newCriterion); - } - - function onChangedInput(event: React.ChangeEvent) { - valueStage.current = event.target.value; - } - - function onChangedDuration(valueAsNumber: number) { - valueStage.current = valueAsNumber; - onBlurInput(); - } - - function onBlurInput() { - const newCriterion = _.cloneDeep(criterion); - newCriterion.value = valueStage.current; + newCriterion.value = value; setCriterion(newCriterion); } function onAddFilter() { - if (!Array.isArray(criterion.value) && defaultValue.current !== undefined) { - const value = defaultValue.current; - if ( - options && - (value === undefined || value === "" || typeof value === "number") - ) { - criterion.value = options[0].toString(); - } else if (typeof value === "number" && value === undefined) { - criterion.value = 0; - } else if (value === undefined) { - criterion.value = ""; - } - } const oldId = editingCriterion ? editingCriterion.getId() : undefined; onAddCriterion(criterion, oldId); } @@ -151,136 +128,57 @@ export const AddFilterDialog: React.FC = ({ return; } - if (Array.isArray(criterion.value)) { - if ( - criterion.criterionOption.type !== "performers" && - criterion.criterionOption.type !== "studios" && - criterion.criterionOption.type !== "parent_studios" && - criterion.criterionOption.type !== "tags" && - criterion.criterionOption.type !== "sceneTags" && - criterion.criterionOption.type !== "performerTags" && - criterion.criterionOption.type !== "movies" - ) - return; - + if (criterion instanceof ILabeledIdCriterion) { return ( - { - const newCriterion = _.cloneDeep(criterion); - newCriterion.value = items.map((i) => ({ - id: i.id, - label: i.name!, - })); - setCriterion(newCriterion); - }} - ids={criterion.value.map((labeled) => labeled.id)} + ); } if (criterion instanceof IHierarchicalLabeledIdCriterion) { - if (criterion.criterionOption.type !== "studios") return; - return ( - { - const newCriterion = _.cloneDeep(criterion); - newCriterion.value.items = items.map((i) => ({ - id: i.id, - label: i.name!, - })); - setCriterion(newCriterion); - }} - ids={criterion.value.items.map((labeled) => labeled.id)} + ); } - if (options && !criterionIsHierarchicalLabelValue(criterion.value)) { + if ( + options && + !criterionIsHierarchicalLabelValue(criterion.value) && + !criterionIsNumberValue(criterion.value) && + !Array.isArray(criterion.value) + ) { defaultValue.current = criterion.value; return ( - - {options.map((c) => ( - - ))} - + ); } if (criterion instanceof DurationCriterion) { - // render duration control return ( - ); } - return ( - - ); - } - function renderAdditional() { - if (criterion instanceof IHierarchicalLabeledIdCriterion) { + if (criterion instanceof NumberCriterion) { return ( - <> - - { - const newCriterion = _.cloneDeep(criterion); - newCriterion.value.depth = - newCriterion.value.depth !== 0 ? 0 : -1; - setCriterion(newCriterion); - }} - /> - - {criterion.value.depth !== 0 && ( - - { - const newCriterion = _.cloneDeep(criterion); - newCriterion.value.depth = e.target.value - ? parseInt(e.target.value, 10) - : -1; - setCriterion(newCriterion); - }} - defaultValue={ - criterion.value && criterion.value.depth !== -1 - ? criterion.value.depth - : "" - } - min="1" - /> - - )} - + ); } + return ( + + ); } return ( <> {renderModifier()} - {renderSelect()} - {renderAdditional()} + {renderSelect()} ); }; diff --git a/ui/v2.5/src/components/List/Filters/DurationFilter.tsx b/ui/v2.5/src/components/List/Filters/DurationFilter.tsx new file mode 100644 index 000000000..3fffa954a --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/DurationFilter.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { DurationInput } from "../../Shared"; +import { INumberValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; + +interface IDurationFilterProps { + criterion: Criterion; + onValueChanged: (value: INumberValue) => void; +} + +export const DurationFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + function onChanged(valueAsNumber: number, property: "value" | "value2") { + const { value } = criterion; + value[property] = valueAsNumber; + onValueChanged(value); + } + + let equalsControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals + ) { + equalsControl = ( + + onChanged(v, "value")} + placeholder={intl.formatMessage({ id: "criterion.value" })} + /> + + ); + } + + let lowerControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.GreaterThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + lowerControl = ( + + onChanged(v, "value")} + placeholder={intl.formatMessage({ id: "criterion.greater_than" })} + /> + + ); + } + + let upperControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.LessThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + upperControl = ( + + + onChanged( + v, + criterion.modifier === CriterionModifier.LessThan + ? "value" + : "value2" + ) + } + placeholder={intl.formatMessage({ id: "criterion.less_than" })} + /> + + ); + } + + return ( + <> + {equalsControl} + {lowerControl} + {upperControl} + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx new file mode 100644 index 000000000..151a813c6 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { defineMessages, useIntl } from "react-intl"; +import { FilterSelect, ValidTypes } from "../../Shared"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; +import { IHierarchicalLabelValue } from "../../../models/list-filter/types"; + +interface IHierarchicalLabelValueFilterProps { + criterion: Criterion; + onValueChanged: (value: IHierarchicalLabelValue) => void; +} + +export const HierarchicalLabelValueFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + if ( + criterion.criterionOption.type !== "performers" && + criterion.criterionOption.type !== "studios" && + criterion.criterionOption.type !== "parent_studios" && + criterion.criterionOption.type !== "tags" && + criterion.criterionOption.type !== "sceneTags" && + criterion.criterionOption.type !== "performerTags" && + criterion.criterionOption.type !== "movies" + ) + return null; + + const messages = defineMessages({ + studio_depth: { + id: "studio_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + + function onSelectionChanged(items: ValidTypes[]) { + const { value } = criterion; + value.items = items.map((i) => ({ + id: i.id, + label: i.name!, + })); + onValueChanged(value); + } + + function onDepthChanged(depth: number) { + const { value } = criterion; + value.depth = depth; + onValueChanged(value); + } + + return ( + <> + + labeled.id)} + /> + + + + onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} + /> + + + {criterion.value.depth !== 0 && ( + + + onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) + } + defaultValue={ + criterion.value && criterion.value.depth !== -1 + ? criterion.value.depth + : "" + } + min="1" + /> + + )} + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/InputFilter.tsx b/ui/v2.5/src/components/List/Filters/InputFilter.tsx new file mode 100644 index 000000000..95e6ce15d --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/InputFilter.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { + Criterion, + CriterionValue, +} from "../../../models/list-filter/criteria/criterion"; + +interface IInputFilterProps { + criterion: Criterion; + onValueChanged: (value: string) => void; +} + +export const InputFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + function onChanged(event: React.ChangeEvent) { + onValueChanged(event.target.value); + } + + return ( + + + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx new file mode 100644 index 000000000..df47f49dc --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { FilterSelect, ValidTypes } from "../../Shared"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; +import { ILabeledId } from "../../../models/list-filter/types"; + +interface ILabeledIdFilterProps { + criterion: Criterion; + onValueChanged: (value: ILabeledId[]) => void; +} + +export const LabeledIdFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + if ( + criterion.criterionOption.type !== "performers" && + criterion.criterionOption.type !== "studios" && + criterion.criterionOption.type !== "parent_studios" && + criterion.criterionOption.type !== "tags" && + criterion.criterionOption.type !== "sceneTags" && + criterion.criterionOption.type !== "performerTags" && + criterion.criterionOption.type !== "movies" + ) + return null; + + function onSelectionChanged(items: ValidTypes[]) { + onValueChanged( + items.map((i) => ({ + id: i.id, + label: i.name!, + })) + ); + } + + return ( + + labeled.id)} + /> + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/NumberFilter.tsx b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx new file mode 100644 index 000000000..d636b5fbd --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx @@ -0,0 +1,114 @@ +import React, { useRef } from "react"; +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"; + +interface IDurationFilterProps { + criterion: Criterion; + onValueChanged: (value: INumberValue) => void; +} + +export const NumberFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + const valueStage = useRef(criterion.value); + + function onChanged( + event: React.ChangeEvent, + property: "value" | "value2" + ) { + const value = parseInt(event.target.value, 10); + valueStage.current[property] = !Number.isNaN(value) ? value : 0; + } + + function onBlurInput() { + onValueChanged(valueStage.current); + } + + let equalsControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals + ) { + equalsControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={intl.formatMessage({ id: "criterion.value" })} + /> + + ); + } + + let lowerControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.GreaterThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + lowerControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={intl.formatMessage({ id: "criterion.greater_than" })} + /> + + ); + } + + let upperControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.LessThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + upperControl = ( + + ) => + onChanged( + e, + criterion.modifier === CriterionModifier.LessThan + ? "value" + : "value2" + ) + } + onBlur={onBlurInput} + defaultValue={ + (criterion.modifier === CriterionModifier.LessThan + ? criterion.value?.value + : criterion.value?.value2) ?? "" + } + placeholder={intl.formatMessage({ id: "criterion.less_than" })} + /> + + ); + } + + return ( + <> + {equalsControl} + {lowerControl} + {upperControl} + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx new file mode 100644 index 000000000..c0d6baead --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { + Criterion, + CriterionValue, +} from "../../../models/list-filter/criteria/criterion"; + +interface IOptionsFilterProps { + criterion: Criterion; + onValueChanged: (value: CriterionValue) => void; +} + +export const OptionsFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + function onChanged(event: React.ChangeEvent) { + onValueChanged(event.target.value); + } + + const options = criterion.criterionOption.options ?? []; + + if ( + options && + (criterion.value === undefined || + criterion.value === "" || + typeof criterion.value === "number") + ) { + onValueChanged(options[0].toString()); + } + + return ( + + + {options.map((c) => ( + + ))} + + + ); +}; diff --git a/ui/v2.5/src/components/Shared/DurationInput.tsx b/ui/v2.5/src/components/Shared/DurationInput.tsx index d8363b882..4329ffcb2 100644 --- a/ui/v2.5/src/components/Shared/DurationInput.tsx +++ b/ui/v2.5/src/components/Shared/DurationInput.tsx @@ -13,6 +13,7 @@ interface IProps { ): void; onReset?(): void; className?: string; + placeholder?: string; } export const DurationInput: React.FC = (props: IProps) => { @@ -108,7 +109,13 @@ export const DurationInput: React.FC = (props: IProps) => { props.onValueChange(undefined); } }} - placeholder={!props.disabled ? "hh:mm:ss" : undefined} + placeholder={ + !props.disabled + ? props.placeholder + ? `${props.placeholder} (hh:mm:ss)` + : "hh:mm:ss" + : undefined + } /> {maybeRenderReset()} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8477bb1aa..c38e54134 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -380,6 +380,11 @@ "country": "Country", "cover_image": "Cover Image", "created_at": "Created At", + "criterion": { + "greater_than": "Greater than", + "less_than": "Less than", + "value": "Value" + }, "criterion_modifier": { "equals": "is", "excludes": "excludes", @@ -392,7 +397,9 @@ "matches_regex": "matches regex", "not_equals": "is not", "not_matches_regex": "not matches regex", - "not_null": "is not null" + "not_null": "is not null", + "between": "between", + "not_between": "not between" }, "date": "Date", "death_date": "Death Date", 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 2e2b8b380..26d2cac03 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -4,24 +4,26 @@ import { IntlShape } from "react-intl"; import { CriterionModifier, HierarchicalMultiCriterionInput, + IntCriterionInput, MultiCriterionInput, } from "src/core/generated-graphql"; import DurationUtils from "src/utils/duration"; import { CriterionType, encodeILabeledId, + IHierarchicalLabelValue, ILabeledId, ILabeledValue, + INumberValue, IOptionType, - IHierarchicalLabelValue, } from "../types"; export type Option = string | number | IOptionType; export type CriterionValue = | string - | number | ILabeledId[] - | IHierarchicalLabelValue; + | IHierarchicalLabelValue + | INumberValue; const modifierMessageIDs = { [CriterionModifier.Equals]: "criterion_modifier.equals", @@ -35,6 +37,8 @@ const modifierMessageIDs = { [CriterionModifier.Excludes]: "criterion_modifier.excludes", [CriterionModifier.MatchesRegex]: "criterion_modifier.matches_regex", [CriterionModifier.NotMatchesRegex]: "criterion_modifier.not_matches_regex", + [CriterionModifier.Between]: "criterion_modifier.between", + [CriterionModifier.NotBetween]: "criterion_modifier.not_between", }; // V = criterion value type @@ -293,6 +297,8 @@ export class NumberCriterionOption extends CriterionOption { CriterionModifier.LessThan, CriterionModifier.IsNull, CriterionModifier.NotNull, + CriterionModifier.Between, + CriterionModifier.NotBetween, ], defaultModifier: CriterionModifier.Equals, options, @@ -305,13 +311,42 @@ export function createNumberCriterionOption(value: CriterionType) { return new NumberCriterionOption(value, value, value); } -export class NumberCriterion extends Criterion { +export class NumberCriterion extends Criterion { + private getValue() { + // backwards compatibility - if this.value is a number, use that + if (typeof this.value !== "object") { + return this.value as number; + } + + return this.value.value; + } + + public encodeValue() { + return { + value: this.getValue(), + value2: this.value.value2, + }; + } + + protected toCriterionInput(): IntCriterionInput { + // backwards compatibility - if this.value is a number, use that + return { + modifier: this.modifier, + value: this.getValue(), + value2: this.value.value2, + }; + } + public getLabelValue() { - return this.value.toString(); + const value = this.getValue(); + return this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween + ? `${value}, ${this.value.value2 ?? 0}` + : `${value}`; } constructor(type: CriterionOption) { - super(type, 0); + super(type, { value: 0, value2: undefined }); } } @@ -415,6 +450,8 @@ export class MandatoryNumberCriterionOption extends CriterionOption { CriterionModifier.NotEquals, CriterionModifier.GreaterThan, CriterionModifier.LessThan, + CriterionModifier.Between, + CriterionModifier.NotBetween, ], defaultModifier: CriterionModifier.Equals, inputType: "number", @@ -426,12 +463,37 @@ export function createMandatoryNumberCriterionOption(value: CriterionType) { return new MandatoryNumberCriterionOption(value, value, value); } -export class DurationCriterion extends Criterion { +export class DurationCriterion extends Criterion { constructor(type: CriterionOption) { - super(type, 0); + super(type, { value: 0, value2: undefined }); + } + + public encodeValue() { + return { + value: this.value.value, + value2: this.value.value2, + }; + } + + protected toCriterionInput(): IntCriterionInput { + return { + modifier: this.modifier, + value: this.value.value, + value2: this.value.value2, + }; } public getLabelValue() { - return DurationUtils.secondsToString(this.value); + return this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween + ? `${DurationUtils.secondsToString( + this.value.value + )} ${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) + : "?"; } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 025dc3050..910ac78c1 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -23,6 +23,11 @@ export interface IHierarchicalLabelValue { depth: number; } +export interface INumberValue { + value: number; + value2: number | undefined; +} + export function criterionIsHierarchicalLabelValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any @@ -30,6 +35,13 @@ export function criterionIsHierarchicalLabelValue( return typeof value === "object" && "items" in value && "depth" in value; } +export function criterionIsNumberValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any +): value is INumberValue { + return typeof value === "object" && "value" in value && "value2" in value; +} + export function encodeILabeledId(o: ILabeledId) { // escape " and \ and by encoding to JSON so that it encodes to JSON correctly down the line const adjustedLabel = JSON.stringify(o.label).slice(1, -1);