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,
"""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!
}

View File

@@ -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...)
}
}
}

View File

@@ -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...)
}
}
}

View File

@@ -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...)
}
}

View File

@@ -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() {

View File

@@ -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)
}

View File

@@ -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...)
}

View File

@@ -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))

View File

@@ -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<IAddFilterProps> = ({
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<IAddFilterProps> = ({
}
}, [editingCriterion]);
useEffect(() => {
valueStage.current = criterion.value;
}, [criterion]);
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterionType = event.target.value as CriterionType;
const newCriterion = makeCriteria(newCriterionType);
@@ -78,41 +83,13 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
setCriterion(newCriterion);
}
function onChangedSingleSelect(event: React.ChangeEvent<HTMLSelectElement>) {
function onValueChanged(value: CriterionValue) {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value = event.target.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;
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<IAddFilterProps> = ({
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 (
<FilterSelect
type={criterion.criterionOption.type}
isMulti
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)}
<LabeledIdFilter
criterion={criterion}
onValueChanged={onValueChanged}
/>
);
}
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
if (criterion.criterionOption.type !== "studios") return;
return (
<FilterSelect
type={criterion.criterionOption.type}
isMulti
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)}
<HierarchicalLabelValueFilter
criterion={criterion}
onValueChanged={onValueChanged}
/>
);
}
if (options && !criterionIsHierarchicalLabelValue(criterion.value)) {
if (
options &&
!criterionIsHierarchicalLabelValue(criterion.value) &&
!criterionIsNumberValue(criterion.value) &&
!Array.isArray(criterion.value)
) {
defaultValue.current = criterion.value;
return (
<Form.Control
as="select"
onChange={onChangedSingleSelect}
value={criterion.value.toString()}
className="btn-secondary"
>
{options.map((c) => (
<option key={c.toString()} value={c.toString()}>
{c}
</option>
))}
</Form.Control>
<OptionsFilter
criterion={criterion}
onValueChanged={onValueChanged}
/>
);
}
if (criterion instanceof DurationCriterion) {
// render duration control
return (
<DurationInput
numericValue={criterion.value ? criterion.value : 0}
onValueChange={onChangedDuration}
<DurationFilter
criterion={criterion}
onValueChanged={onValueChanged}
/>
);
}
if (criterion instanceof NumberCriterion) {
return (
<Form.Control
className="btn-secondary"
type={criterion.criterionOption.inputType}
onChange={onChangedInput}
onBlur={onBlurInput}
defaultValue={criterion.value ? criterion.value.toString() : ""}
/>
<NumberFilter criterion={criterion} onValueChanged={onValueChanged} />
);
}
function renderAdditional() {
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
return (
<>
<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>
)}
</>
<InputFilter criterion={criterion} onValueChanged={onValueChanged} />
);
}
}
return (
<>
<Form.Group>{renderModifier()}</Form.Group>
<Form.Group>{renderSelect()}</Form.Group>
{renderAdditional()}
{renderSelect()}
</>
);
};

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;
onReset?(): void;
className?: string;
placeholder?: string;
}
export const DurationInput: React.FC<IProps> = (props: IProps) => {
@@ -108,7 +109,13 @@ export const DurationInput: React.FC<IProps> = (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
}
/>
<InputGroup.Append>
{maybeRenderReset()}

View File

@@ -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",

View File

@@ -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<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() {
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<number> {
export class DurationCriterion extends Criterion<INumberValue> {
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)
: "?";
}
}

View File

@@ -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);