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>
This commit is contained in:
gitgiggety
2021-08-12 02:24:16 +02:00
committed by GitHub
parent c29d8b547d
commit 7cb3d05535
19 changed files with 623 additions and 278 deletions

View File

@@ -327,6 +327,10 @@ enum CriterionModifier {
MATCHES_REGEX, MATCHES_REGEX,
"""NOT MATCHES REGEX""" """NOT MATCHES REGEX"""
NOT_MATCHES_REGEX, NOT_MATCHES_REGEX,
""">= AND <="""
BETWEEN,
"""< OR >"""
NOT_BETWEEN,
} }
input StringCriterionInput { input StringCriterionInput {
@@ -336,6 +340,7 @@ input StringCriterionInput {
input IntCriterionInput { input IntCriterionInput {
value: Int! value: Int!
value2: Int
modifier: CriterionModifier! modifier: CriterionModifier!
} }

View File

@@ -368,13 +368,8 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
func intCriterionHandler(c *models.IntCriterionInput, column string) criterionHandlerFunc { func intCriterionHandler(c *models.IntCriterionInput, column string) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if c != nil { if c != nil {
clause, count := getIntCriterionWhereClause(column, *c) clause, args := getIntCriterionWhereClause(column, *c)
f.addWhere(clause, args...)
if count == 1 {
f.addWhere(clause, c.Value)
} else {
f.addWhere(clause)
}
} }
} }
} }
@@ -495,13 +490,9 @@ type countCriterionHandlerBuilder struct {
func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if criterion != nil { 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, args...)
f.addWhere(clause, criterion.Value)
} else {
f.addWhere(clause)
}
} }
} }
} }

View File

@@ -3,7 +3,6 @@ package sqlite
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strconv"
"strings" "strings"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -356,25 +355,8 @@ func performerIsMissingCriterionHandler(qb *performerQueryBuilder, isMissing *st
func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if year != nil && year.Modifier.IsValid() { if year != nil && year.Modifier.IsValid() {
yearStr := strconv.Itoa(year.Value) clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year)
startOfYear := yearStr + "-01-01" f.addWhere(clause, args...)
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)
}
} }
} }
} }
@@ -382,22 +364,11 @@ func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) crit
func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if age != nil && age.Modifier.IsValid() { if age != nil && age.Modifier.IsValid() {
var op string 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)",
switch age.Modifier { *age,
case models.CriterionModifierEquals: )
op = "==" f.addWhere(clause, args...)
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)
}
} }
} }
} }

View File

@@ -135,11 +135,9 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) {
func (qb *queryBuilder) handleIntCriterionInput(c *models.IntCriterionInput, column string) { func (qb *queryBuilder) handleIntCriterionInput(c *models.IntCriterionInput, column string) {
if c != nil { if c != nil {
clause, count := getIntCriterionWhereClause(column, *c) clause, args := getIntCriterionWhereClause(column, *c)
qb.addWhere(clause) qb.addWhere(clause)
if count == 1 { qb.addArg(args...)
qb.addArg(c.Value)
}
} }
} }
@@ -192,12 +190,9 @@ func (qb *queryBuilder) handleStringCriterionInput(c *models.StringCriterionInpu
func (qb *queryBuilder) handleCountCriterion(countFilter *models.IntCriterionInput, primaryTable, joinTable, primaryFK string) { func (qb *queryBuilder) handleCountCriterion(countFilter *models.IntCriterionInput, primaryTable, joinTable, primaryFK string) {
if countFilter != nil { if countFilter != nil {
clause, count := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter) clause, args := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter)
if count == 1 {
qb.addArg(countFilter.Value)
}
qb.addWhere(clause) qb.addWhere(clause)
qb.addArg(args...)
} }
} }

View File

@@ -461,40 +461,12 @@ func phashCriterionHandler(phashFilter *models.StringCriterionInput) criterionHa
func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc { func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if durationFilter != nil { if durationFilter != nil {
clause, thisArgs := getDurationWhereClause(*durationFilter, column) clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter)
f.addWhere(clause, thisArgs...) 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 { func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if resolution != nil && resolution.Value.IsValid() { if resolution != nil && resolution.Value.IsValid() {

View File

@@ -160,7 +160,7 @@ func getCriterionModifierBinding(criterionModifier models.CriterionModifier, val
} }
if modifier := criterionModifier.String(); criterionModifier.IsValid() { if modifier := criterionModifier.String(); criterionModifier.IsValid() {
switch modifier { 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, "?") return getSimpleCriterionClause(criterionModifier, "?")
case "INCLUDES": case "INCLUDES":
return "IN " + getInBinding(length), length // TODO? return "IN " + getInBinding(length), length // TODO?
@@ -189,6 +189,10 @@ func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs st
return "IS NULL", 0 return "IS NULL", 0
case "NOT_NULL": case "NOT_NULL":
return "IS NOT NULL", 0 return "IS NOT NULL", 0
case "BETWEEN":
return "BETWEEN (" + rhs + ") AND (" + rhs + ")", 2
case "NOT_BETWEEN":
return "NOT BETWEEN (" + rhs + ") AND (" + rhs + ")", 2
default: default:
logger.Errorf("todo") logger.Errorf("todo")
return "= ?", 1 // TODO return "= ?", 1 // TODO
@@ -198,9 +202,30 @@ func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs st
return "= ?", 1 // TODO return "= ?", 1 // TODO
} }
func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, int) { func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) {
binding, count := getCriterionModifierBinding(input.Modifier, input.Value) binding, _ := getSimpleCriterionClause(input.Modifier, "?")
return column + " " + binding, count 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 // returns where clause and having clause
@@ -226,7 +251,7 @@ func getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, f
return whereClause, havingClause 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) lhs := fmt.Sprintf("(SELECT COUNT(*) FROM %s s WHERE s.%s = %s.id)", joinTable, primaryFK, primaryTable)
return getIntCriterionWhereClause(lhs, criterion) return getIntCriterionWhereClause(lhs, criterion)
} }

View File

@@ -359,12 +359,7 @@ func tagSceneCountCriterionHandler(qb *tagQueryBuilder, sceneCount *models.IntCr
return func(f *filterBuilder) { return func(f *filterBuilder) {
if sceneCount != nil { if sceneCount != nil {
f.addJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id") f.addJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount)
args := []interface{}{}
if count == 1 {
args = append(args, sceneCount.Value)
}
f.addHaving(clause, args...) f.addHaving(clause, args...)
} }
@@ -375,12 +370,7 @@ func tagImageCountCriterionHandler(qb *tagQueryBuilder, imageCount *models.IntCr
return func(f *filterBuilder) { return func(f *filterBuilder) {
if imageCount != nil { if imageCount != nil {
f.addJoin("images_tags", "", "images_tags.tag_id = tags.id") f.addJoin("images_tags", "", "images_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount)
args := []interface{}{}
if count == 1 {
args = append(args, imageCount.Value)
}
f.addHaving(clause, args...) f.addHaving(clause, args...)
} }
@@ -391,12 +381,7 @@ func tagGalleryCountCriterionHandler(qb *tagQueryBuilder, galleryCount *models.I
return func(f *filterBuilder) { return func(f *filterBuilder) {
if galleryCount != nil { if galleryCount != nil {
f.addJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id") f.addJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount)
args := []interface{}{}
if count == 1 {
args = append(args, galleryCount.Value)
}
f.addHaving(clause, args...) f.addHaving(clause, args...)
} }
@@ -407,12 +392,7 @@ func tagPerformerCountCriterionHandler(qb *tagQueryBuilder, performerCount *mode
return func(f *filterBuilder) { return func(f *filterBuilder) {
if performerCount != nil { if performerCount != nil {
f.addJoin("performers_tags", "", "performers_tags.tag_id = tags.id") f.addJoin("performers_tags", "", "performers_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount)
args := []interface{}{}
if count == 1 {
args = append(args, performerCount.Value)
}
f.addHaving(clause, args...) f.addHaving(clause, args...)
} }
@@ -424,12 +404,7 @@ func tagMarkerCountCriterionHandler(qb *tagQueryBuilder, markerCount *models.Int
if markerCount != nil { if markerCount != nil {
f.addJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id") 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") 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) clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount)
args := []interface{}{}
if count == 1 {
args = append(args, markerCount.Value)
}
f.addHaving(clause, args...) f.addHaving(clause, args...)
} }

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ 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 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)) * 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)) * Added filtering and sorting on scene marker count for tags. ([#1603](https://github.com/stashapp/stash/pull/1603))

View File

@@ -1,13 +1,14 @@
import _ from "lodash"; import _ from "lodash";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap"; import { Button, Form, Modal } from "react-bootstrap";
import { FilterSelect, DurationInput } from "src/components/Shared";
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
DurationCriterion, DurationCriterion,
CriterionValue, CriterionValue,
Criterion, Criterion,
IHierarchicalLabeledIdCriterion, IHierarchicalLabeledIdCriterion,
NumberCriterion,
ILabeledIdCriterion,
} from "src/models/list-filter/criteria/criterion"; } from "src/models/list-filter/criteria/criterion";
import { import {
NoneCriterion, NoneCriterion,
@@ -15,11 +16,18 @@ import {
} from "src/models/list-filter/criteria/none"; } from "src/models/list-filter/criteria/none";
import { makeCriteria } from "src/models/list-filter/criteria/factory"; import { makeCriteria } from "src/models/list-filter/criteria/factory";
import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { defineMessages, FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { import {
criterionIsHierarchicalLabelValue, criterionIsHierarchicalLabelValue,
criterionIsNumberValue,
CriterionType, CriterionType,
} from "src/models/list-filter/types"; } 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 { interface IAddFilterProps {
onAddCriterion: ( onAddCriterion: (
@@ -48,13 +56,6 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
const intl = useIntl(); const intl = useIntl();
const messages = defineMessages({
studio_depth: {
id: "studio_depth",
defaultMessage: "Levels (empty for all)",
},
});
// Configure if we are editing an existing criterion // Configure if we are editing an existing criterion
useEffect(() => { useEffect(() => {
if (!editingCriterion) { if (!editingCriterion) {
@@ -64,6 +65,10 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
} }
}, [editingCriterion]); }, [editingCriterion]);
useEffect(() => {
valueStage.current = criterion.value;
}, [criterion]);
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) { function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterionType = event.target.value as CriterionType; const newCriterionType = event.target.value as CriterionType;
const newCriterion = makeCriteria(newCriterionType); const newCriterion = makeCriteria(newCriterionType);
@@ -78,41 +83,13 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
setCriterion(newCriterion); setCriterion(newCriterion);
} }
function onChangedSingleSelect(event: React.ChangeEvent<HTMLSelectElement>) { function onValueChanged(value: CriterionValue) {
const newCriterion = _.cloneDeep(criterion); const newCriterion = _.cloneDeep(criterion);
newCriterion.value = event.target.value; newCriterion.value = value;
setCriterion(newCriterion);
}
function onChangedInput(event: React.ChangeEvent<HTMLInputElement>) {
valueStage.current = event.target.value;
}
function onChangedDuration(valueAsNumber: number) {
valueStage.current = valueAsNumber;
onBlurInput();
}
function onBlurInput() {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value = valueStage.current;
setCriterion(newCriterion); setCriterion(newCriterion);
} }
function onAddFilter() { 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; const oldId = editingCriterion ? editingCriterion.getId() : undefined;
onAddCriterion(criterion, oldId); onAddCriterion(criterion, oldId);
} }
@@ -151,136 +128,57 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
return; return;
} }
if (Array.isArray(criterion.value)) { if (criterion instanceof ILabeledIdCriterion) {
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;
return ( return (
<FilterSelect <LabeledIdFilter
type={criterion.criterionOption.type} criterion={criterion}
isMulti onValueChanged={onValueChanged}
onSelect={(items) => {
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 instanceof IHierarchicalLabeledIdCriterion) {
if (criterion.criterionOption.type !== "studios") return;
return ( return (
<FilterSelect <HierarchicalLabelValueFilter
type={criterion.criterionOption.type} criterion={criterion}
isMulti onValueChanged={onValueChanged}
onSelect={(items) => {
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; defaultValue.current = criterion.value;
return ( return (
<Form.Control <OptionsFilter
as="select" criterion={criterion}
onChange={onChangedSingleSelect} onValueChanged={onValueChanged}
value={criterion.value.toString()} />
className="btn-secondary"
>
{options.map((c) => (
<option key={c.toString()} value={c.toString()}>
{c}
</option>
))}
</Form.Control>
); );
} }
if (criterion instanceof DurationCriterion) { if (criterion instanceof DurationCriterion) {
// render duration control
return ( return (
<DurationInput <DurationFilter
numericValue={criterion.value ? criterion.value : 0} criterion={criterion}
onValueChange={onChangedDuration} onValueChanged={onValueChanged}
/> />
); );
} }
if (criterion instanceof NumberCriterion) {
return ( return (
<Form.Control <NumberFilter criterion={criterion} onValueChanged={onValueChanged} />
className="btn-secondary"
type={criterion.criterionOption.inputType}
onChange={onChangedInput}
onBlur={onBlurInput}
defaultValue={criterion.value ? criterion.value.toString() : ""}
/>
); );
} }
function renderAdditional() {
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
return ( return (
<> <InputFilter criterion={criterion} onValueChanged={onValueChanged} />
<Form.Group>
<Form.Check
checked={criterion.value.depth !== 0}
label={intl.formatMessage({ id: "include_child_studios" })}
onChange={() => {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value.depth =
newCriterion.value.depth !== 0 ? 0 : -1;
setCriterion(newCriterion);
}}
/>
</Form.Group>
{criterion.value.depth !== 0 && (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
placeholder={intl.formatMessage(messages.studio_depth)}
onChange={(e) => {
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"
/>
</Form.Group>
)}
</>
); );
} }
}
return ( return (
<> <>
<Form.Group>{renderModifier()}</Form.Group> <Form.Group>{renderModifier()}</Form.Group>
<Form.Group>{renderSelect()}</Form.Group> {renderSelect()}
{renderAdditional()}
</> </>
); );
}; };

View File

@@ -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<INumberValue>;
onValueChanged: (value: INumberValue) => void;
}
export const DurationFilter: React.FC<IDurationFilterProps> = ({
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 = (
<Form.Group>
<DurationInput
numericValue={criterion.value?.value}
onValueChange={(v: number) => onChanged(v, "value")}
placeholder={intl.formatMessage({ id: "criterion.value" })}
/>
</Form.Group>
);
}
let lowerControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.GreaterThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
lowerControl = (
<Form.Group>
<DurationInput
numericValue={criterion.value?.value}
onValueChange={(v: number) => onChanged(v, "value")}
placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
/>
</Form.Group>
);
}
let upperControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.LessThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
upperControl = (
<Form.Group>
<DurationInput
numericValue={
criterion.modifier === CriterionModifier.LessThan
? criterion.value?.value
: criterion.value?.value2
}
onValueChange={(v: number) =>
onChanged(
v,
criterion.modifier === CriterionModifier.LessThan
? "value"
: "value2"
)
}
placeholder={intl.formatMessage({ id: "criterion.less_than" })}
/>
</Form.Group>
);
}
return (
<>
{equalsControl}
{lowerControl}
{upperControl}
</>
);
};

View File

@@ -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<IHierarchicalLabelValue>;
onValueChanged: (value: IHierarchicalLabelValue) => void;
}
export const HierarchicalLabelValueFilter: React.FC<IHierarchicalLabelValueFilterProps> = ({
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 (
<>
<Form.Group>
<FilterSelect
type={criterion.criterionOption.type}
isMulti
onSelect={onSelectionChanged}
ids={criterion.value.items.map((labeled) => labeled.id)}
/>
</Form.Group>
<Form.Group>
<Form.Check
checked={criterion.value.depth !== 0}
label={intl.formatMessage({ id: "include_child_studios" })}
onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)}
/>
</Form.Group>
{criterion.value.depth !== 0 && (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
placeholder={intl.formatMessage(messages.studio_depth)}
onChange={(e) =>
onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)
}
defaultValue={
criterion.value && criterion.value.depth !== -1
? criterion.value.depth
: ""
}
min="1"
/>
</Form.Group>
)}
</>
);
};

View File

@@ -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<CriterionValue>;
onValueChanged: (value: string) => void;
}
export const InputFilter: React.FC<IInputFilterProps> = ({
criterion,
onValueChanged,
}) => {
function onChanged(event: React.ChangeEvent<HTMLInputElement>) {
onValueChanged(event.target.value);
}
return (
<Form.Group>
<Form.Control
className="btn-secondary"
type={criterion.criterionOption.inputType}
onBlur={onChanged}
defaultValue={criterion.value ? criterion.value.toString() : ""}
/>
</Form.Group>
);
};

View File

@@ -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<ILabeledId[]>;
onValueChanged: (value: ILabeledId[]) => void;
}
export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
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 (
<Form.Group>
<FilterSelect
type={criterion.criterionOption.type}
isMulti
onSelect={onSelectionChanged}
ids={criterion.value.map((labeled) => labeled.id)}
/>
</Form.Group>
);
};

View File

@@ -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<INumberValue>;
onValueChanged: (value: INumberValue) => void;
}
export const NumberFilter: React.FC<IDurationFilterProps> = ({
criterion,
onValueChanged,
}) => {
const intl = useIntl();
const valueStage = useRef<INumberValue>(criterion.value);
function onChanged(
event: React.ChangeEvent<HTMLInputElement>,
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 = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
onBlur={onBlurInput}
defaultValue={criterion.value?.value ?? ""}
placeholder={intl.formatMessage({ id: "criterion.value" })}
/>
</Form.Group>
);
}
let lowerControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.GreaterThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
lowerControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
onBlur={onBlurInput}
defaultValue={criterion.value?.value ?? ""}
placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
/>
</Form.Group>
);
}
let upperControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.LessThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
upperControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
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" })}
/>
</Form.Group>
);
}
return (
<>
{equalsControl}
{lowerControl}
{upperControl}
</>
);
};

View File

@@ -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<CriterionValue>;
onValueChanged: (value: CriterionValue) => void;
}
export const OptionsFilter: React.FC<IOptionsFilterProps> = ({
criterion,
onValueChanged,
}) => {
function onChanged(event: React.ChangeEvent<HTMLSelectElement>) {
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 (
<Form.Group>
<Form.Control
as="select"
onChange={onChanged}
value={criterion.value.toString()}
className="btn-secondary"
>
{options.map((c) => (
<option key={c.toString()} value={c.toString()}>
{c}
</option>
))}
</Form.Control>
</Form.Group>
);
};

View File

@@ -13,6 +13,7 @@ interface IProps {
): void; ): void;
onReset?(): void; onReset?(): void;
className?: string; className?: string;
placeholder?: string;
} }
export const DurationInput: React.FC<IProps> = (props: IProps) => { export const DurationInput: React.FC<IProps> = (props: IProps) => {
@@ -108,7 +109,13 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
props.onValueChange(undefined); props.onValueChange(undefined);
} }
}} }}
placeholder={!props.disabled ? "hh:mm:ss" : undefined} placeholder={
!props.disabled
? props.placeholder
? `${props.placeholder} (hh:mm:ss)`
: "hh:mm:ss"
: undefined
}
/> />
<InputGroup.Append> <InputGroup.Append>
{maybeRenderReset()} {maybeRenderReset()}

View File

@@ -380,6 +380,11 @@
"country": "Country", "country": "Country",
"cover_image": "Cover Image", "cover_image": "Cover Image",
"created_at": "Created At", "created_at": "Created At",
"criterion": {
"greater_than": "Greater than",
"less_than": "Less than",
"value": "Value"
},
"criterion_modifier": { "criterion_modifier": {
"equals": "is", "equals": "is",
"excludes": "excludes", "excludes": "excludes",
@@ -392,7 +397,9 @@
"matches_regex": "matches regex", "matches_regex": "matches regex",
"not_equals": "is not", "not_equals": "is not",
"not_matches_regex": "not matches regex", "not_matches_regex": "not matches regex",
"not_null": "is not null" "not_null": "is not null",
"between": "between",
"not_between": "not between"
}, },
"date": "Date", "date": "Date",
"death_date": "Death Date", "death_date": "Death Date",

View File

@@ -4,24 +4,26 @@ import { IntlShape } from "react-intl";
import { import {
CriterionModifier, CriterionModifier,
HierarchicalMultiCriterionInput, HierarchicalMultiCriterionInput,
IntCriterionInput,
MultiCriterionInput, MultiCriterionInput,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import DurationUtils from "src/utils/duration"; import DurationUtils from "src/utils/duration";
import { import {
CriterionType, CriterionType,
encodeILabeledId, encodeILabeledId,
IHierarchicalLabelValue,
ILabeledId, ILabeledId,
ILabeledValue, ILabeledValue,
INumberValue,
IOptionType, IOptionType,
IHierarchicalLabelValue,
} from "../types"; } from "../types";
export type Option = string | number | IOptionType; export type Option = string | number | IOptionType;
export type CriterionValue = export type CriterionValue =
| string | string
| number
| ILabeledId[] | ILabeledId[]
| IHierarchicalLabelValue; | IHierarchicalLabelValue
| INumberValue;
const modifierMessageIDs = { const modifierMessageIDs = {
[CriterionModifier.Equals]: "criterion_modifier.equals", [CriterionModifier.Equals]: "criterion_modifier.equals",
@@ -35,6 +37,8 @@ const modifierMessageIDs = {
[CriterionModifier.Excludes]: "criterion_modifier.excludes", [CriterionModifier.Excludes]: "criterion_modifier.excludes",
[CriterionModifier.MatchesRegex]: "criterion_modifier.matches_regex", [CriterionModifier.MatchesRegex]: "criterion_modifier.matches_regex",
[CriterionModifier.NotMatchesRegex]: "criterion_modifier.not_matches_regex", [CriterionModifier.NotMatchesRegex]: "criterion_modifier.not_matches_regex",
[CriterionModifier.Between]: "criterion_modifier.between",
[CriterionModifier.NotBetween]: "criterion_modifier.not_between",
}; };
// V = criterion value type // V = criterion value type
@@ -293,6 +297,8 @@ export class NumberCriterionOption extends CriterionOption {
CriterionModifier.LessThan, CriterionModifier.LessThan,
CriterionModifier.IsNull, CriterionModifier.IsNull,
CriterionModifier.NotNull, CriterionModifier.NotNull,
CriterionModifier.Between,
CriterionModifier.NotBetween,
], ],
defaultModifier: CriterionModifier.Equals, defaultModifier: CriterionModifier.Equals,
options, options,
@@ -305,13 +311,42 @@ export function createNumberCriterionOption(value: CriterionType) {
return new NumberCriterionOption(value, value, value); return new NumberCriterionOption(value, value, value);
} }
export class NumberCriterion extends Criterion<number> { export class NumberCriterion extends Criterion<INumberValue> {
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() { 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) { constructor(type: CriterionOption) {
super(type, 0); super(type, { value: 0, value2: undefined });
} }
} }
@@ -415,6 +450,8 @@ export class MandatoryNumberCriterionOption extends CriterionOption {
CriterionModifier.NotEquals, CriterionModifier.NotEquals,
CriterionModifier.GreaterThan, CriterionModifier.GreaterThan,
CriterionModifier.LessThan, CriterionModifier.LessThan,
CriterionModifier.Between,
CriterionModifier.NotBetween,
], ],
defaultModifier: CriterionModifier.Equals, defaultModifier: CriterionModifier.Equals,
inputType: "number", inputType: "number",
@@ -426,12 +463,37 @@ export function createMandatoryNumberCriterionOption(value: CriterionType) {
return new MandatoryNumberCriterionOption(value, value, value); return new MandatoryNumberCriterionOption(value, value, value);
} }
export class DurationCriterion extends Criterion<number> { export class DurationCriterion extends Criterion<INumberValue> {
constructor(type: CriterionOption) { 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() { 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)
: "?";
} }
} }

View File

@@ -23,6 +23,11 @@ export interface IHierarchicalLabelValue {
depth: number; depth: number;
} }
export interface INumberValue {
value: number;
value2: number | undefined;
}
export function criterionIsHierarchicalLabelValue( export function criterionIsHierarchicalLabelValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any value: any
@@ -30,6 +35,13 @@ export function criterionIsHierarchicalLabelValue(
return typeof value === "object" && "items" in value && "depth" in value; 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) { export function encodeILabeledId(o: ILabeledId) {
// escape " and \ and by encoding to JSON so that it encodes to JSON correctly down the line // 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); const adjustedLabel = JSON.stringify(o.label).slice(1, -1);