mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
UI filter builder (#3515)
* Add clear criteria button * Add count to filter button
This commit is contained in:
@@ -1,348 +0,0 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import {
|
||||
DurationCriterion,
|
||||
CriterionValue,
|
||||
Criterion,
|
||||
IHierarchicalLabeledIdCriterion,
|
||||
NumberCriterion,
|
||||
ILabeledIdCriterion,
|
||||
DateCriterion,
|
||||
TimestampCriterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import {
|
||||
NoneCriterion,
|
||||
NoneCriterionOption,
|
||||
} from "src/models/list-filter/criteria/none";
|
||||
import { makeCriteria } from "src/models/list-filter/criteria/factory";
|
||||
import { ListFilterOptions } from "src/models/list-filter/filter-options";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import {
|
||||
criterionIsHierarchicalLabelValue,
|
||||
criterionIsNumberValue,
|
||||
criterionIsStashIDValue,
|
||||
criterionIsDateValue,
|
||||
criterionIsTimestampValue,
|
||||
CriterionType,
|
||||
} from "src/models/list-filter/types";
|
||||
import { DurationFilter } from "./Filters/DurationFilter";
|
||||
import { NumberFilter } from "./Filters/NumberFilter";
|
||||
import { LabeledIdFilter } from "./Filters/LabeledIdFilter";
|
||||
import { HierarchicalLabelValueFilter } from "./Filters/HierarchicalLabelValueFilter";
|
||||
import { OptionsFilter } from "./Filters/OptionsFilter";
|
||||
import { InputFilter } from "./Filters/InputFilter";
|
||||
import { DateFilter } from "./Filters/DateFilter";
|
||||
import { TimestampFilter } from "./Filters/TimestampFilter";
|
||||
import { CountryCriterion } from "src/models/list-filter/criteria/country";
|
||||
import { CountrySelect } from "../Shared/CountrySelect";
|
||||
import { StashIDCriterion } from "src/models/list-filter/criteria/stash-ids";
|
||||
import { StashIDFilter } from "./Filters/StashIDFilter";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { RatingCriterion } from "../../models/list-filter/criteria/rating";
|
||||
import { RatingFilter } from "./Filters/RatingFilter";
|
||||
|
||||
interface IAddFilterProps {
|
||||
onAddCriterion: (
|
||||
criterion: Criterion<CriterionValue>,
|
||||
oldId?: string
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
filterOptions: ListFilterOptions;
|
||||
editingCriterion?: Criterion<CriterionValue>;
|
||||
existingCriterions: Criterion<CriterionValue>[];
|
||||
}
|
||||
|
||||
export const AddFilterDialog: React.FC<IAddFilterProps> = ({
|
||||
onAddCriterion,
|
||||
onCancel,
|
||||
filterOptions,
|
||||
editingCriterion,
|
||||
existingCriterions,
|
||||
}) => {
|
||||
const defaultValue = useRef<string | number | undefined>();
|
||||
|
||||
const [criterion, setCriterion] = useState<Criterion<CriterionValue>>(
|
||||
new NoneCriterion()
|
||||
);
|
||||
const { options, modifierOptions } = criterion.criterionOption;
|
||||
|
||||
const valueStage = useRef<CriterionValue>(criterion.value);
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
// Configure if we are editing an existing criterion
|
||||
useEffect(() => {
|
||||
if (!editingCriterion) {
|
||||
setCriterion(makeCriteria(config));
|
||||
} else {
|
||||
setCriterion(editingCriterion);
|
||||
}
|
||||
}, [config, editingCriterion]);
|
||||
|
||||
useEffect(() => {
|
||||
valueStage.current = criterion.value;
|
||||
}, [criterion]);
|
||||
|
||||
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const newCriterionType = event.target.value as CriterionType;
|
||||
const newCriterion = makeCriteria(config, newCriterionType);
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onChangedModifierSelect(
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) {
|
||||
const newCriterion = cloneDeep(criterion);
|
||||
newCriterion.modifier = event.target.value as CriterionModifier;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onValueChanged(value: CriterionValue) {
|
||||
const newCriterion = cloneDeep(criterion);
|
||||
newCriterion.value = value;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onAddFilter() {
|
||||
const oldId = editingCriterion ? editingCriterion.getId() : undefined;
|
||||
onAddCriterion(criterion, oldId);
|
||||
}
|
||||
|
||||
const maybeRenderFilterPopoverContents = () => {
|
||||
if (criterion.criterionOption.type === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
function renderModifier() {
|
||||
if (modifierOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={onChangedModifierSelect}
|
||||
value={criterion.modifier}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{modifierOptions.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label ? intl.formatMessage({ id: c.label }) : ""}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSelect() {
|
||||
// always show stashID filter
|
||||
if (criterion instanceof StashIDCriterion) {
|
||||
return (
|
||||
<StashIDFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Hide the value select if the modifier is "IsNull" or "NotNull"
|
||||
if (
|
||||
criterion.modifier === CriterionModifier.IsNull ||
|
||||
criterion.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (criterion instanceof ILabeledIdCriterion) {
|
||||
return (
|
||||
<LabeledIdFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
|
||||
return (
|
||||
<HierarchicalLabelValueFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
options &&
|
||||
!criterionIsHierarchicalLabelValue(criterion.value) &&
|
||||
!criterionIsNumberValue(criterion.value) &&
|
||||
!criterionIsStashIDValue(criterion.value) &&
|
||||
!criterionIsDateValue(criterion.value) &&
|
||||
!criterionIsTimestampValue(criterion.value) &&
|
||||
!Array.isArray(criterion.value)
|
||||
) {
|
||||
defaultValue.current = criterion.value;
|
||||
return (
|
||||
<OptionsFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (criterion instanceof DurationCriterion) {
|
||||
return (
|
||||
<DurationFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (criterion instanceof DateCriterion) {
|
||||
return (
|
||||
<DateFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (criterion instanceof TimestampCriterion) {
|
||||
return (
|
||||
<TimestampFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (criterion instanceof NumberCriterion) {
|
||||
return (
|
||||
<NumberFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (criterion instanceof RatingCriterion) {
|
||||
return (
|
||||
<RatingFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (
|
||||
criterion instanceof CountryCriterion &&
|
||||
(criterion.modifier === CriterionModifier.Equals ||
|
||||
criterion.modifier === CriterionModifier.NotEquals)
|
||||
) {
|
||||
return (
|
||||
<CountrySelect
|
||||
value={criterion.value}
|
||||
onChange={(v) => onValueChanged(v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<InputFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form.Group>{renderModifier()}</Form.Group>
|
||||
{renderSelect()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function maybeRenderFilterCriterion() {
|
||||
if (!editingCriterion) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<strong>
|
||||
{intl.formatMessage({
|
||||
id: editingCriterion.criterionOption.messageID,
|
||||
})}
|
||||
</strong>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderFilterSelect() {
|
||||
if (editingCriterion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thisOptions = [NoneCriterionOption]
|
||||
.concat(filterOptions.criterionOptions)
|
||||
.filter(
|
||||
(c) =>
|
||||
!existingCriterions.find((ec) => ec.criterionOption.type === c.type)
|
||||
)
|
||||
.map((c) => {
|
||||
return {
|
||||
value: c.type,
|
||||
text: intl.formatMessage({ id: c.messageID }),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.value === "none") return -1;
|
||||
if (b.value === "none") return 1;
|
||||
return a.text.localeCompare(b.text);
|
||||
});
|
||||
|
||||
return (
|
||||
<Form.Group controlId="filter">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={onChangedCriteriaType}
|
||||
value={criterion.criterionOption.type}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{thisOptions.map((c) => (
|
||||
<option key={c.value} value={c.value} disabled={c.value === "none"}>
|
||||
{c.text}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function isValid() {
|
||||
if (criterion.criterionOption.type === "none") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (criterion instanceof RatingCriterion) {
|
||||
switch (criterion.modifier) {
|
||||
case CriterionModifier.Equals:
|
||||
case CriterionModifier.NotEquals:
|
||||
case CriterionModifier.LessThan:
|
||||
return !!criterion.value.value;
|
||||
case CriterionModifier.Between:
|
||||
case CriterionModifier.NotBetween:
|
||||
return criterion.value.value < (criterion.value.value2 ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const title = !editingCriterion
|
||||
? intl.formatMessage({ id: "search_filter.add_filter" })
|
||||
: intl.formatMessage({ id: "search_filter.update_filter" });
|
||||
return (
|
||||
<>
|
||||
<Modal show onHide={() => onCancel()} className="add-filter-dialog">
|
||||
<Modal.Header>{title}</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="dialog-content">
|
||||
{maybeRenderFilterSelect()}
|
||||
{maybeRenderFilterCriterion()}
|
||||
{maybeRenderFilterPopoverContents()}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={onAddFilter} disabled={!isValid()}>
|
||||
{title}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
218
ui/v2.5/src/components/List/CriterionEditor.tsx
Normal file
218
ui/v2.5/src/components/List/CriterionEditor.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import {
|
||||
DurationCriterion,
|
||||
CriterionValue,
|
||||
Criterion,
|
||||
IHierarchicalLabeledIdCriterion,
|
||||
NumberCriterion,
|
||||
ILabeledIdCriterion,
|
||||
DateCriterion,
|
||||
TimestampCriterion,
|
||||
BooleanCriterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { useIntl } from "react-intl";
|
||||
import {
|
||||
criterionIsHierarchicalLabelValue,
|
||||
criterionIsNumberValue,
|
||||
criterionIsStashIDValue,
|
||||
criterionIsDateValue,
|
||||
criterionIsTimestampValue,
|
||||
} from "src/models/list-filter/types";
|
||||
import { DurationFilter } from "./Filters/DurationFilter";
|
||||
import { NumberFilter } from "./Filters/NumberFilter";
|
||||
import { LabeledIdFilter } from "./Filters/LabeledIdFilter";
|
||||
import { HierarchicalLabelValueFilter } from "./Filters/HierarchicalLabelValueFilter";
|
||||
import { InputFilter } from "./Filters/InputFilter";
|
||||
import { DateFilter } from "./Filters/DateFilter";
|
||||
import { TimestampFilter } from "./Filters/TimestampFilter";
|
||||
import { CountryCriterion } from "src/models/list-filter/criteria/country";
|
||||
import { CountrySelect } from "../Shared/CountrySelect";
|
||||
import { StashIDCriterion } from "src/models/list-filter/criteria/stash-ids";
|
||||
import { StashIDFilter } from "./Filters/StashIDFilter";
|
||||
import { RatingCriterion } from "../../models/list-filter/criteria/rating";
|
||||
import { RatingFilter } from "./Filters/RatingFilter";
|
||||
import { BooleanFilter } from "./Filters/BooleanFilter";
|
||||
import { OptionsListFilter } from "./Filters/OptionsListFilter";
|
||||
|
||||
interface IGenericCriterionEditor {
|
||||
criterion: Criterion<CriterionValue>;
|
||||
setCriterion: (c: Criterion<CriterionValue>) => void;
|
||||
}
|
||||
|
||||
const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { options, modifierOptions } = criterion.criterionOption;
|
||||
|
||||
const onChangedModifierSelect = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newCriterion = cloneDeep(criterion);
|
||||
newCriterion.modifier = event.target.value as CriterionModifier;
|
||||
setCriterion(newCriterion);
|
||||
},
|
||||
[criterion, setCriterion]
|
||||
);
|
||||
|
||||
const modifierSelector = useMemo(() => {
|
||||
if (!modifierOptions || modifierOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={onChangedModifierSelect}
|
||||
value={criterion.modifier}
|
||||
className="btn-secondary modifier-selector"
|
||||
>
|
||||
{modifierOptions.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label ? intl.formatMessage({ id: c.label }) : ""}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
);
|
||||
}, [modifierOptions, onChangedModifierSelect, criterion.modifier, intl]);
|
||||
|
||||
const valueControl = useMemo(() => {
|
||||
function onValueChanged(value: CriterionValue) {
|
||||
const newCriterion = cloneDeep(criterion);
|
||||
newCriterion.value = value;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
// always show stashID filter
|
||||
if (criterion instanceof StashIDCriterion) {
|
||||
return (
|
||||
<StashIDFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
|
||||
// Hide the value select if the modifier is "IsNull" or "NotNull"
|
||||
if (
|
||||
criterion.modifier === CriterionModifier.IsNull ||
|
||||
criterion.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (criterion instanceof ILabeledIdCriterion) {
|
||||
return (
|
||||
<LabeledIdFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
|
||||
return (
|
||||
<HierarchicalLabelValueFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
options &&
|
||||
!criterionIsHierarchicalLabelValue(criterion.value) &&
|
||||
!criterionIsNumberValue(criterion.value) &&
|
||||
!criterionIsStashIDValue(criterion.value) &&
|
||||
!criterionIsDateValue(criterion.value) &&
|
||||
!criterionIsTimestampValue(criterion.value) &&
|
||||
!Array.isArray(criterion.value)
|
||||
) {
|
||||
// if (!modifierOptions || modifierOptions.length === 0) {
|
||||
return (
|
||||
<OptionsListFilter criterion={criterion} setCriterion={setCriterion} />
|
||||
);
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <OptionsFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
// );
|
||||
}
|
||||
if (criterion instanceof DurationCriterion) {
|
||||
return (
|
||||
<DurationFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (criterion instanceof DateCriterion) {
|
||||
return (
|
||||
<DateFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (criterion instanceof TimestampCriterion) {
|
||||
return (
|
||||
<TimestampFilter
|
||||
criterion={criterion}
|
||||
onValueChanged={onValueChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (criterion instanceof NumberCriterion) {
|
||||
return (
|
||||
<NumberFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (criterion instanceof RatingCriterion) {
|
||||
return (
|
||||
<RatingFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}
|
||||
if (
|
||||
criterion instanceof CountryCriterion &&
|
||||
(criterion.modifier === CriterionModifier.Equals ||
|
||||
criterion.modifier === CriterionModifier.NotEquals)
|
||||
) {
|
||||
return (
|
||||
<CountrySelect
|
||||
value={criterion.value}
|
||||
onChange={(v) => onValueChanged(v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<InputFilter criterion={criterion} onValueChanged={onValueChanged} />
|
||||
);
|
||||
}, [criterion, setCriterion, options]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{modifierSelector}
|
||||
{valueControl}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ICriterionEditor {
|
||||
criterion: Criterion<CriterionValue>;
|
||||
setCriterion: (c: Criterion<CriterionValue>) => void;
|
||||
}
|
||||
|
||||
export const CriterionEditor: React.FC<ICriterionEditor> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
}) => {
|
||||
const filterControl = useMemo(() => {
|
||||
if (criterion instanceof BooleanCriterion) {
|
||||
return (
|
||||
<BooleanFilter criterion={criterion} setCriterion={setCriterion} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericCriterionEditor
|
||||
criterion={criterion}
|
||||
setCriterion={setCriterion}
|
||||
/>
|
||||
);
|
||||
}, [criterion, setCriterion]);
|
||||
|
||||
return <div className="criterion-editor">{filterControl}</div>;
|
||||
};
|
||||
340
ui/v2.5/src/components/List/EditFilterDialog.tsx
Normal file
340
ui/v2.5/src/components/List/EditFilterDialog.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Accordion, Button, Card, Modal } from "react-bootstrap";
|
||||
import cx from "classnames";
|
||||
import {
|
||||
CriterionValue,
|
||||
Criterion,
|
||||
CriterionOption,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { makeCriteria } from "src/models/list-filter/criteria/factory";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getFilterOptions } from "src/models/list-filter/factory";
|
||||
import { FilterTags } from "./FilterTags";
|
||||
import { CriterionEditor } from "./CriterionEditor";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useCompare, usePrevious } from "src/hooks/state";
|
||||
import { CriterionType } from "src/models/list-filter/types";
|
||||
|
||||
interface ICriterionList {
|
||||
criteria: string[];
|
||||
currentCriterion?: Criterion<CriterionValue>;
|
||||
setCriterion: (c: Criterion<CriterionValue>) => void;
|
||||
criterionOptions: CriterionOption[];
|
||||
selected?: CriterionOption;
|
||||
optionSelected: (o?: CriterionOption) => void;
|
||||
onRemoveCriterion: (c: string) => void;
|
||||
}
|
||||
|
||||
const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
criteria,
|
||||
currentCriterion,
|
||||
setCriterion,
|
||||
criterionOptions,
|
||||
selected,
|
||||
optionSelected,
|
||||
onRemoveCriterion,
|
||||
}) => {
|
||||
const prevCriterion = usePrevious(currentCriterion);
|
||||
|
||||
const scrolled = useRef(false);
|
||||
|
||||
const type = currentCriterion?.criterionOption.type;
|
||||
const prevType = prevCriterion?.criterionOption.type;
|
||||
|
||||
const criteriaRefs = useMemo(() => {
|
||||
const refs: Record<string, React.RefObject<HTMLDivElement>> = {};
|
||||
criterionOptions.forEach((c) => {
|
||||
refs[c.type] = React.createRef();
|
||||
});
|
||||
return refs;
|
||||
}, [criterionOptions]);
|
||||
|
||||
function onSelect(k: string | null) {
|
||||
if (!k) {
|
||||
optionSelected(undefined);
|
||||
return;
|
||||
}
|
||||
const option = criterionOptions.find((c) => c.type === k);
|
||||
|
||||
if (option) {
|
||||
optionSelected(option);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// scrolling to the current criterion doesn't work well when the
|
||||
// dialog is already open, so limit to when we click on the
|
||||
// criterion from the external tags
|
||||
if (!scrolled.current && type && criteriaRefs[type]?.current) {
|
||||
criteriaRefs[type].current!.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
scrolled.current = true;
|
||||
}
|
||||
}, [currentCriterion, criteriaRefs, type]);
|
||||
|
||||
function getReleventCriterion(t: CriterionType) {
|
||||
if (currentCriterion?.criterionOption.type === t) {
|
||||
return currentCriterion;
|
||||
}
|
||||
|
||||
return prevCriterion;
|
||||
}
|
||||
|
||||
function removeClicked(ev: React.MouseEvent, t: string) {
|
||||
// needed to prevent the nav item from being selected
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
onRemoveCriterion(t);
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
className="criterion-list"
|
||||
activeKey={selected?.type}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
{criterionOptions.map((c) => (
|
||||
<Card key={c.type} data-type={c.type} ref={criteriaRefs[c.type]!}>
|
||||
<Accordion.Toggle eventKey={c.type} as={Card.Header}>
|
||||
<span>
|
||||
<Icon
|
||||
className="collapse-icon fa-fw"
|
||||
icon={type === c.type ? faChevronDown : faChevronRight}
|
||||
/>
|
||||
<FormattedMessage id={c.messageID} />
|
||||
</span>
|
||||
{criteria.some((cc) => c.type === cc) && (
|
||||
<Button
|
||||
className="remove-criterion-button"
|
||||
variant="minimal"
|
||||
onClick={(e) => removeClicked(e, c.type)}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
)}
|
||||
</Accordion.Toggle>
|
||||
<Accordion.Collapse eventKey={c.type}>
|
||||
{(type === c.type && currentCriterion) ||
|
||||
(prevType === c.type && prevCriterion) ? (
|
||||
<Card.Body>
|
||||
<CriterionEditor
|
||||
criterion={getReleventCriterion(c.type)!}
|
||||
setCriterion={setCriterion}
|
||||
/>
|
||||
</Card.Body>
|
||||
) : (
|
||||
<Card.Body></Card.Body>
|
||||
)}
|
||||
</Accordion.Collapse>
|
||||
</Card>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
interface IEditFilterProps {
|
||||
filter: ListFilterModel;
|
||||
editingCriterion?: string;
|
||||
onApply: (filter: ListFilterModel) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
filter,
|
||||
editingCriterion,
|
||||
onApply,
|
||||
onCancel,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
|
||||
const [currentFilter, setCurrentFilter] = useState<ListFilterModel>(
|
||||
cloneDeep(filter)
|
||||
);
|
||||
const [criterion, setCriterion] = useState<Criterion<CriterionValue>>();
|
||||
|
||||
const { criteria } = currentFilter;
|
||||
|
||||
const criteriaList = useMemo(() => {
|
||||
return criteria.map((c) => c.criterionOption.type);
|
||||
}, [criteria]);
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
return getFilterOptions(currentFilter.mode);
|
||||
}, [currentFilter.mode]);
|
||||
|
||||
const criterionOptions = useMemo(() => {
|
||||
const filteredOptions = filterOptions.criterionOptions.filter((o) => {
|
||||
return o.type !== "none";
|
||||
});
|
||||
|
||||
filteredOptions.sort((a, b) => {
|
||||
return intl
|
||||
.formatMessage({ id: a.messageID })
|
||||
.localeCompare(intl.formatMessage({ id: b.messageID }));
|
||||
});
|
||||
|
||||
return filteredOptions;
|
||||
}, [intl, filterOptions.criterionOptions]);
|
||||
|
||||
const optionSelected = useCallback(
|
||||
(option?: CriterionOption) => {
|
||||
if (!option) {
|
||||
setCriterion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// find the existing criterion if present
|
||||
const existing = criteria.find(
|
||||
(c) => c.criterionOption.type === option.type
|
||||
);
|
||||
if (existing) {
|
||||
setCriterion(existing);
|
||||
} else {
|
||||
const newCriterion = makeCriteria(config, option.type);
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
},
|
||||
[criteria, config]
|
||||
);
|
||||
|
||||
const editingCriterionChanged = useCompare(editingCriterion);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCriterionChanged && editingCriterion) {
|
||||
const option = criterionOptions.find((c) => c.type === editingCriterion);
|
||||
if (option) {
|
||||
optionSelected(option);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
editingCriterion,
|
||||
criterionOptions,
|
||||
optionSelected,
|
||||
editingCriterionChanged,
|
||||
]);
|
||||
|
||||
function replaceCriterion(c: Criterion<CriterionValue>) {
|
||||
const newFilter = cloneDeep(currentFilter);
|
||||
|
||||
if (!c.isValid()) {
|
||||
// remove from the filter if present
|
||||
const newCriteria = criteria.filter((cc) => {
|
||||
return cc.criterionOption.type !== c.criterionOption.type;
|
||||
});
|
||||
|
||||
newFilter.criteria = newCriteria;
|
||||
} else {
|
||||
let found = false;
|
||||
|
||||
const newCriteria = criteria.map((cc) => {
|
||||
if (cc.criterionOption.type === c.criterionOption.type) {
|
||||
found = true;
|
||||
return c;
|
||||
}
|
||||
|
||||
return cc;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
newCriteria.push(c);
|
||||
}
|
||||
|
||||
newFilter.criteria = newCriteria;
|
||||
}
|
||||
|
||||
setCurrentFilter(newFilter);
|
||||
setCriterion(c);
|
||||
}
|
||||
|
||||
function removeCriterion(c: Criterion<CriterionValue>) {
|
||||
const newFilter = cloneDeep(currentFilter);
|
||||
|
||||
const newCriteria = criteria.filter((cc) => {
|
||||
return cc.getId() !== c.getId();
|
||||
});
|
||||
|
||||
newFilter.criteria = newCriteria;
|
||||
|
||||
setCurrentFilter(newFilter);
|
||||
if (criterion?.getId() === c.getId()) {
|
||||
optionSelected(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCriterionString(c: string) {
|
||||
const cc = criteria.find((ccc) => ccc.criterionOption.type === c);
|
||||
if (cc) {
|
||||
removeCriterion(cc);
|
||||
}
|
||||
}
|
||||
|
||||
function onClearAll() {
|
||||
const newFilter = cloneDeep(currentFilter);
|
||||
newFilter.criteria = [];
|
||||
setCurrentFilter(newFilter);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal show onHide={() => onCancel()} className="edit-filter-dialog">
|
||||
<Modal.Header>
|
||||
<FormattedMessage id="search_filter.edit_filter" />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div
|
||||
className={cx("dialog-content", {
|
||||
"criterion-selected": !!criterion,
|
||||
})}
|
||||
>
|
||||
<CriterionOptionList
|
||||
criteria={criteriaList}
|
||||
currentCriterion={criterion}
|
||||
setCriterion={replaceCriterion}
|
||||
criterionOptions={criterionOptions}
|
||||
optionSelected={optionSelected}
|
||||
selected={criterion?.criterionOption}
|
||||
onRemoveCriterion={(c) => removeCriterionString(c)}
|
||||
/>
|
||||
{criteria.length > 0 && (
|
||||
<div>
|
||||
<FilterTags
|
||||
criteria={criteria}
|
||||
onEditCriterion={(c) => optionSelected(c.criterionOption)}
|
||||
onRemoveCriterion={(c) => removeCriterion(c)}
|
||||
onRemoveAll={() => onClearAll()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => onCancel()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
<Button onClick={() => onApply(currentFilter)}>
|
||||
<FormattedMessage id="actions.apply" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Criterion,
|
||||
CriterionValue,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { useIntl } from "react-intl";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
@@ -12,12 +12,14 @@ interface IFilterTagsProps {
|
||||
criteria: Criterion<CriterionValue>[];
|
||||
onEditCriterion: (c: Criterion<CriterionValue>) => void;
|
||||
onRemoveCriterion: (c: Criterion<CriterionValue>) => void;
|
||||
onRemoveAll: () => void;
|
||||
}
|
||||
|
||||
export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
criteria,
|
||||
onEditCriterion,
|
||||
onRemoveCriterion,
|
||||
onRemoveAll,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -55,9 +57,26 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
));
|
||||
}
|
||||
|
||||
function maybeRenderClearAll() {
|
||||
if (criteria.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="clear-all-button"
|
||||
onClick={() => onRemoveAll()}
|
||||
>
|
||||
<FormattedMessage id="actions.clear" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-center mb-2 wrap-tags">
|
||||
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
|
||||
{renderFilterTags()}
|
||||
{maybeRenderClearAll()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
45
ui/v2.5/src/components/List/Filters/BooleanFilter.tsx
Normal file
45
ui/v2.5/src/components/List/Filters/BooleanFilter.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { BooleanCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IBooleanFilter {
|
||||
criterion: BooleanCriterion;
|
||||
setCriterion: (c: BooleanCriterion) => void;
|
||||
}
|
||||
|
||||
export const BooleanFilter: React.FC<IBooleanFilter> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
}) => {
|
||||
function onSelect(v: boolean) {
|
||||
const c = cloneDeep(criterion);
|
||||
if ((v && c.value === "true") || (!v && c.value === "false")) {
|
||||
c.value = "";
|
||||
} else {
|
||||
c.value = v ? "true" : "false";
|
||||
}
|
||||
|
||||
setCriterion(c);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="boolean-filter">
|
||||
<Form.Check
|
||||
id={`${criterion.getId()}-true`}
|
||||
onChange={() => onSelect(true)}
|
||||
checked={criterion.value === "true"}
|
||||
type="checkbox"
|
||||
label={<FormattedMessage id="true" />}
|
||||
/>
|
||||
<Form.Check
|
||||
id={`${criterion.getId()}-false`}
|
||||
onChange={() => onSelect(false)}
|
||||
checked={criterion.value === "false"}
|
||||
type="checkbox"
|
||||
label={<FormattedMessage id="false" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react";
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { CriterionModifier } from "../../../core/generated-graphql";
|
||||
@@ -16,18 +16,17 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const valueStage = useRef<IDateValue>(criterion.value);
|
||||
const { value } = criterion;
|
||||
|
||||
function onChanged(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
property: "value" | "value2"
|
||||
) {
|
||||
const { value } = event.target;
|
||||
valueStage.current[property] = value;
|
||||
}
|
||||
const newValue = event.target.value;
|
||||
const valueCopy = { ...value };
|
||||
|
||||
function onBlurInput() {
|
||||
onValueChanged(valueStage.current);
|
||||
valueCopy[property] = newValue;
|
||||
onValueChanged(valueCopy);
|
||||
}
|
||||
|
||||
let equalsControl: JSX.Element | null = null;
|
||||
@@ -43,8 +42,7 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChanged(e, "value")
|
||||
}
|
||||
onBlur={onBlurInput}
|
||||
defaultValue={criterion.value?.value ?? ""}
|
||||
value={value?.value ?? ""}
|
||||
placeholder={
|
||||
intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD)"
|
||||
}
|
||||
@@ -67,8 +65,7 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChanged(e, "value")
|
||||
}
|
||||
onBlur={onBlurInput}
|
||||
defaultValue={criterion.value?.value ?? ""}
|
||||
value={value?.value ?? ""}
|
||||
placeholder={
|
||||
intl.formatMessage({ id: "criterion.greater_than" }) +
|
||||
" (YYYY-MM-DD)"
|
||||
@@ -97,11 +94,10 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
|
||||
: "value2"
|
||||
)
|
||||
}
|
||||
onBlur={onBlurInput}
|
||||
defaultValue={
|
||||
value={
|
||||
(criterion.modifier === CriterionModifier.LessThan
|
||||
? criterion.value?.value
|
||||
: criterion.value?.value2) ?? ""
|
||||
? value?.value
|
||||
: value?.value2) ?? ""
|
||||
}
|
||||
placeholder={
|
||||
intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD)"
|
||||
|
||||
28
ui/v2.5/src/components/List/Filters/FilterButton.tsx
Normal file
28
ui/v2.5/src/components/List/Filters/FilterButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Badge, Button } from "react-bootstrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
|
||||
interface IFilterButtonProps {
|
||||
filter: ListFilterModel;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const FilterButton: React.FC<IFilterButtonProps> = ({
|
||||
filter,
|
||||
onClick,
|
||||
}) => {
|
||||
const count = useMemo(() => filter.count(), [filter]);
|
||||
|
||||
return (
|
||||
<Button variant="secondary" className="filter-button" onClick={onClick}>
|
||||
<Icon icon={faFilter} />
|
||||
{count ? (
|
||||
<Badge pill variant="danger">
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -80,6 +80,7 @@ export const HierarchicalLabelValueFilter: React.FC<
|
||||
isMulti
|
||||
onSelect={onSelectionChanged}
|
||||
ids={criterion.value.items.map((labeled) => labeled.id)}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
|
||||
@@ -19,13 +19,15 @@ export const InputFilter: React.FC<IInputFilterProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
type={criterion.criterionOption.inputType}
|
||||
onBlur={onChanged}
|
||||
defaultValue={criterion.value ? criterion.value.toString() : ""}
|
||||
/>
|
||||
</Form.Group>
|
||||
<>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
type={criterion.criterionOption.inputType}
|
||||
onChange={onChanged}
|
||||
value={criterion.value ? criterion.value.toString() : ""}
|
||||
/>
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
|
||||
isMulti
|
||||
onSelect={onSelectionChanged}
|
||||
ids={criterion.value.map((labeled) => labeled.id)}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { CriterionModifier } from "../../../core/generated-graphql";
|
||||
import { INumberValue } from "../../../models/list-filter/types";
|
||||
import { Criterion } from "../../../models/list-filter/criteria/criterion";
|
||||
import { NumberCriterion } from "../../../models/list-filter/criteria/criterion";
|
||||
|
||||
interface IDurationFilterProps {
|
||||
criterion: Criterion<INumberValue>;
|
||||
criterion: NumberCriterion;
|
||||
onValueChanged: (value: INumberValue) => void;
|
||||
}
|
||||
|
||||
@@ -16,12 +16,14 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { value } = criterion;
|
||||
|
||||
function onChanged(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
property: "value" | "value2"
|
||||
) {
|
||||
const numericValue = parseInt(event.target.value, 10);
|
||||
const valueCopy = { ...criterion.value };
|
||||
const valueCopy = { ...value };
|
||||
|
||||
valueCopy[property] = !Number.isNaN(numericValue) ? numericValue : 0;
|
||||
onValueChanged(valueCopy);
|
||||
@@ -40,7 +42,7 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChanged(e, "value")
|
||||
}
|
||||
value={criterion.value?.value ?? ""}
|
||||
value={value?.value ?? ""}
|
||||
placeholder={intl.formatMessage({ id: "criterion.value" })}
|
||||
/>
|
||||
</Form.Group>
|
||||
@@ -61,7 +63,7 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChanged(e, "value")
|
||||
}
|
||||
value={criterion.value?.value ?? ""}
|
||||
value={value?.value ?? ""}
|
||||
placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
|
||||
/>
|
||||
</Form.Group>
|
||||
@@ -89,8 +91,8 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
|
||||
}
|
||||
value={
|
||||
(criterion.modifier === CriterionModifier.LessThan
|
||||
? criterion.value?.value
|
||||
: criterion.value?.value2) ?? ""
|
||||
? value?.value
|
||||
: value?.value2) ?? ""
|
||||
}
|
||||
placeholder={intl.formatMessage({ id: "criterion.less_than" })}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import {
|
||||
Criterion,
|
||||
@@ -18,16 +18,13 @@ export const OptionsFilter: React.FC<IOptionsFilterProps> = ({
|
||||
onValueChanged(event.target.value);
|
||||
}
|
||||
|
||||
const options = criterion.criterionOption.options ?? [];
|
||||
const options = useMemo(() => {
|
||||
const ret = criterion.criterionOption.options?.slice() ?? [];
|
||||
|
||||
if (
|
||||
options &&
|
||||
(criterion.value === undefined ||
|
||||
criterion.value === "" ||
|
||||
typeof criterion.value === "number")
|
||||
) {
|
||||
onValueChanged(options[0].toString());
|
||||
}
|
||||
ret.unshift("");
|
||||
|
||||
return ret;
|
||||
}, [criterion.criterionOption.options]);
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
@@ -39,7 +36,7 @@ export const OptionsFilter: React.FC<IOptionsFilterProps> = ({
|
||||
>
|
||||
{options.map((c) => (
|
||||
<option key={c.toString()} value={c.toString()}>
|
||||
{c}
|
||||
{c ? c : "---"}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
|
||||
45
ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx
Normal file
45
ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import {
|
||||
CriterionValue,
|
||||
Criterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
|
||||
interface IOptionsListFilter {
|
||||
criterion: Criterion<CriterionValue>;
|
||||
setCriterion: (c: Criterion<CriterionValue>) => void;
|
||||
}
|
||||
|
||||
export const OptionsListFilter: React.FC<IOptionsListFilter> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
}) => {
|
||||
function onSelect(v: string) {
|
||||
const c = cloneDeep(criterion);
|
||||
if (c.value === v) {
|
||||
c.value = "";
|
||||
} else {
|
||||
c.value = v;
|
||||
}
|
||||
|
||||
setCriterion(c);
|
||||
}
|
||||
|
||||
const { options } = criterion.criterionOption;
|
||||
|
||||
return (
|
||||
<div className="option-list-filter">
|
||||
{options?.map((o) => (
|
||||
<Form.Check
|
||||
id={`${criterion.getId()}-${o.toString()}`}
|
||||
key={o.toString()}
|
||||
onChange={() => onSelect(o.toString())}
|
||||
checked={criterion.value === o.toString()}
|
||||
type="checkbox"
|
||||
label={o.toString()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export const StashIDFilter: React.FC<IStashIDFilterProps> = ({
|
||||
onValueChanged,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { value } = criterion;
|
||||
|
||||
function onEndpointChanged(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
onValueChanged({
|
||||
@@ -35,8 +36,8 @@ export const StashIDFilter: React.FC<IStashIDFilterProps> = ({
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
onBlur={onEndpointChanged}
|
||||
defaultValue={criterion.value ? criterion.value.endpoint : ""}
|
||||
onChange={onEndpointChanged}
|
||||
value={value ? value.endpoint : ""}
|
||||
placeholder={intl.formatMessage({ id: "stash_id_endpoint" })}
|
||||
/>
|
||||
</Form.Group>
|
||||
@@ -45,8 +46,8 @@ export const StashIDFilter: React.FC<IStashIDFilterProps> = ({
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
onBlur={onStashIDChanged}
|
||||
defaultValue={criterion.value ? criterion.value.stashID : ""}
|
||||
onChange={onStashIDChanged}
|
||||
value={value ? value.stashID : ""}
|
||||
placeholder={intl.formatMessage({ id: "stash_id" })}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react";
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { CriterionModifier } from "../../../core/generated-graphql";
|
||||
@@ -16,18 +16,17 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const valueStage = useRef<ITimestampValue>(criterion.value);
|
||||
const { value } = criterion;
|
||||
|
||||
function onChanged(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
property: "value" | "value2"
|
||||
) {
|
||||
const { value } = event.target;
|
||||
valueStage.current[property] = value;
|
||||
}
|
||||
const newValue = event.target.value;
|
||||
const valueCopy = { ...value };
|
||||
|
||||
function onBlurInput() {
|
||||
onValueChanged(valueStage.current);
|
||||
valueCopy[property] = newValue;
|
||||
onValueChanged(valueCopy);
|
||||
}
|
||||
|
||||
let equalsControl: JSX.Element | null = null;
|
||||
@@ -43,11 +42,10 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChanged(e, "value")
|
||||
}
|
||||
onBlur={onBlurInput}
|
||||
defaultValue={criterion.value?.value ?? ""}
|
||||
value={value?.value ?? ""}
|
||||
placeholder={
|
||||
intl.formatMessage({ id: "criterion.value" }) +
|
||||
" (YYYY-MM-DD HH-MM)"
|
||||
" (YYYY-MM-DD HH:MM)"
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
@@ -68,11 +66,10 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChanged(e, "value")
|
||||
}
|
||||
onBlur={onBlurInput}
|
||||
defaultValue={criterion.value?.value ?? ""}
|
||||
value={value?.value ?? ""}
|
||||
placeholder={
|
||||
intl.formatMessage({ id: "criterion.greater_than" }) +
|
||||
" (YYYY-MM-DD HH-MM)"
|
||||
" (YYYY-MM-DD HH:MM)"
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
@@ -98,15 +95,14 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
|
||||
: "value2"
|
||||
)
|
||||
}
|
||||
onBlur={onBlurInput}
|
||||
defaultValue={
|
||||
value={
|
||||
(criterion.modifier === CriterionModifier.LessThan
|
||||
? criterion.value?.value
|
||||
: criterion.value?.value2) ?? ""
|
||||
? value?.value
|
||||
: value?.value2) ?? ""
|
||||
}
|
||||
placeholder={
|
||||
intl.formatMessage({ id: "criterion.less_than" }) +
|
||||
" (YYYY-MM-DD HH-MM)"
|
||||
" (YYYY-MM-DD HH:MM)"
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { getFilterOptions } from "src/models/list-filter/factory";
|
||||
import { useFindDefaultFilter } from "src/core/StashService";
|
||||
import { Pagination, PaginationIndex } from "./Pagination";
|
||||
import { AddFilterDialog } from "./AddFilterDialog";
|
||||
import { EditFilterDialog } from "src/components/List/EditFilterDialog";
|
||||
import { ListFilter } from "./ListFilter";
|
||||
import { FilterTags } from "./FilterTags";
|
||||
import { ListViewOptions } from "./ListViewOptions";
|
||||
@@ -140,7 +140,6 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
onChangePage: _onChangePage,
|
||||
updateFilter,
|
||||
persistState,
|
||||
filterDialog,
|
||||
zoomable,
|
||||
selectable,
|
||||
otherOperations,
|
||||
@@ -154,9 +153,10 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastClickedId, setLastClickedId] = useState<string>();
|
||||
|
||||
const [editingCriterion, setEditingCriterion] =
|
||||
useState<Criterion<CriterionValue>>();
|
||||
const [newCriterion, setNewCriterion] = useState(false);
|
||||
const [editingCriterion, setEditingCriterion] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [showEditFilter, setShowEditFilter] = useState(false);
|
||||
|
||||
const result = useResult(filter);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
@@ -193,7 +193,7 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("f", () => setNewCriterion(true));
|
||||
Mousetrap.bind("f", () => setShowEditFilter(true));
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("f");
|
||||
@@ -432,40 +432,6 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
updateFilter(newFilter);
|
||||
}
|
||||
|
||||
function onAddCriterion(
|
||||
criterion: Criterion<CriterionValue>,
|
||||
oldId?: string
|
||||
) {
|
||||
const newFilter = cloneDeep(filter);
|
||||
|
||||
// Find if we are editing an existing criteria, then modify that. Or create a new one.
|
||||
const existingIndex = newFilter.criteria.findIndex((c) => {
|
||||
// If we modified an existing criterion, then look for the old id.
|
||||
const id = oldId || criterion.getId();
|
||||
return c.getId() === id;
|
||||
});
|
||||
if (existingIndex === -1) {
|
||||
newFilter.criteria.push(criterion);
|
||||
} else {
|
||||
newFilter.criteria[existingIndex] = criterion;
|
||||
}
|
||||
|
||||
// Remove duplicate modifiers
|
||||
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
|
||||
return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos;
|
||||
});
|
||||
|
||||
newFilter.currentPage = 1;
|
||||
updateFilter(newFilter);
|
||||
setEditingCriterion(undefined);
|
||||
setNewCriterion(false);
|
||||
}
|
||||
|
||||
function onCancelAddCriterion() {
|
||||
setEditingCriterion(undefined);
|
||||
setNewCriterion(false);
|
||||
}
|
||||
|
||||
function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
|
||||
const newFilter = cloneDeep(filter);
|
||||
newFilter.criteria = newFilter.criteria.filter(
|
||||
@@ -475,10 +441,22 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
updateFilter(newFilter);
|
||||
}
|
||||
|
||||
function updateCriteria(c: Criterion<CriterionValue>[]) {
|
||||
function onClearAllCriteria() {
|
||||
const newFilter = cloneDeep(filter);
|
||||
newFilter.criteria = c.slice();
|
||||
setNewCriterion(false);
|
||||
newFilter.criteria = [];
|
||||
newFilter.currentPage = 1;
|
||||
updateFilter(newFilter);
|
||||
}
|
||||
|
||||
function onApplyEditFilter(f: ListFilterModel) {
|
||||
setShowEditFilter(false);
|
||||
setEditingCriterion(undefined);
|
||||
updateFilter(f);
|
||||
}
|
||||
|
||||
function onCancelEditFilter() {
|
||||
setShowEditFilter(false);
|
||||
setEditingCriterion(undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -488,8 +466,7 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
onFilterUpdate={updateFilter}
|
||||
filter={filter}
|
||||
filterOptions={filterOptions}
|
||||
openFilterDialog={() => setNewCriterion(true)}
|
||||
filterDialogOpen={newCriterion}
|
||||
openFilterDialog={() => setShowEditFilter(true)}
|
||||
persistState={persistState}
|
||||
/>
|
||||
<ListOperationButtons
|
||||
@@ -510,21 +487,18 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
</ButtonToolbar>
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => setEditingCriterion(c)}
|
||||
onEditCriterion={(c) => setEditingCriterion(c.criterionOption.type)}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onRemoveAll={() => onClearAllCriteria()}
|
||||
/>
|
||||
{(newCriterion || editingCriterion) && !filterDialog && (
|
||||
<AddFilterDialog
|
||||
filterOptions={filterOptions}
|
||||
onAddCriterion={onAddCriterion}
|
||||
onCancel={onCancelAddCriterion}
|
||||
{(showEditFilter || editingCriterion) && (
|
||||
<EditFilterDialog
|
||||
filter={filter}
|
||||
onApply={onApplyEditFilter}
|
||||
onCancel={onCancelEditFilter}
|
||||
editingCriterion={editingCriterion}
|
||||
existingCriterions={filter.criteria}
|
||||
/>
|
||||
)}
|
||||
{newCriterion &&
|
||||
filterDialog &&
|
||||
filterDialog(filter.criteria, (c) => updateCriteria(c))}
|
||||
{isEditDialogOpen &&
|
||||
renderEditDialog &&
|
||||
renderEditDialog(getSelectedData(items, selectedIds), (applied) =>
|
||||
|
||||
@@ -34,17 +34,16 @@ import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faCheck,
|
||||
faFilter,
|
||||
faRandom,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FilterButton } from "./Filters/FilterButton";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
|
||||
interface IListFilterProps {
|
||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||
filter: ListFilterModel;
|
||||
filterOptions: ListFilterOptions;
|
||||
filterDialogOpen?: boolean;
|
||||
persistState?: PersistanceLevel;
|
||||
openFilterDialog: () => void;
|
||||
}
|
||||
@@ -55,7 +54,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
onFilterUpdate,
|
||||
filter,
|
||||
filterOptions,
|
||||
filterDialogOpen,
|
||||
openFilterDialog,
|
||||
persistState,
|
||||
}) => {
|
||||
@@ -289,13 +287,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openFilterDialog()}
|
||||
active={filterDialogOpen}
|
||||
>
|
||||
<Icon icon={faFilter} />
|
||||
</Button>
|
||||
<FilterButton onClick={() => openFilterDialog()} filter={filter} />
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
|
||||
|
||||
@@ -116,3 +116,83 @@ input[type="range"].zoom-slider {
|
||||
.rating-filter .and-divider {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.edit-filter-dialog {
|
||||
.modal-body {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
border-top: 1px solid rgb(16 22 26 / 40%);
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.criterion-list {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
max-height: 550px;
|
||||
overflow-y: auto;
|
||||
|
||||
.card {
|
||||
border: 1px solid rgb(16 22 26 / 40%);
|
||||
box-shadow: none;
|
||||
margin: 0 0 -1px;
|
||||
padding: 0;
|
||||
|
||||
.collapse-icon {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-criterion-button {
|
||||
border: 0;
|
||||
color: $danger;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-filter-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.modifier-selector {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
// to accommodate for caret
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.filter-tags .clear-all-button {
|
||||
color: $text-color;
|
||||
// to match filter pills
|
||||
line-height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
.fa-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
|
||||
// button group has a z-index of 1
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369))
|
||||
|
||||
### 🎨 Improvements
|
||||
* Overhauled filtering interface to allow setting filter criteria from a single dialog. ([#3515](https://github.com/stashapp/stash/pull/3515))
|
||||
* Removed upper limit on page size. ([#3544](https://github.com/stashapp/stash/pull/3544))
|
||||
* Anonymise task now obfuscates Marker titles. ([#3542](https://github.com/stashapp/stash/pull/3542))
|
||||
* Improved Images wall view layout and added Interface settings to adjust the layout. ([#3511](https://github.com/stashapp/stash/pull/3511))
|
||||
|
||||
@@ -32,3 +32,18 @@ export function useInitialState<T>(
|
||||
|
||||
return [value, setValue, setInitialValue];
|
||||
}
|
||||
|
||||
// useCompare is a hook that returns true if the value has changed since the last render.
|
||||
export function useCompare<T>(val: T) {
|
||||
const prevVal = usePrevious(val);
|
||||
return prevVal !== val;
|
||||
}
|
||||
|
||||
// usePrevious is a hook that returns the previous value of a variable.
|
||||
export function usePrevious<T>(value: T) {
|
||||
const ref = React.useRef<T>();
|
||||
React.useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
@@ -286,11 +286,15 @@ div.react-select__control {
|
||||
}
|
||||
}
|
||||
|
||||
div.react-select__menu-portal {
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
div.react-select__menu,
|
||||
div.dropdown-menu {
|
||||
background-color: $secondary;
|
||||
color: $text-color;
|
||||
z-index: 16;
|
||||
z-index: 1600;
|
||||
|
||||
.react-select__option,
|
||||
.dropdown-item {
|
||||
|
||||
@@ -1039,7 +1039,7 @@
|
||||
"scenes": "Scenes",
|
||||
"scenes_updated_at": "Scene Updated At",
|
||||
"search_filter": {
|
||||
"add_filter": "Add Filter",
|
||||
"edit_filter": "Edit Filter",
|
||||
"name": "Filter",
|
||||
"saved_filters": "Saved filters",
|
||||
"update_filter": "Update Filter"
|
||||
|
||||
@@ -70,6 +70,10 @@ export abstract class Criterion<V extends CriterionValue> {
|
||||
this._value = newValue;
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public abstract getLabelValue(intl: IntlShape): string;
|
||||
|
||||
constructor(type: CriterionOption, value: V) {
|
||||
@@ -227,6 +231,14 @@ export class StringCriterion extends Criterion<string> {
|
||||
public getLabelValue(_intl: IntlShape) {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull ||
|
||||
this.value.length > 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MandatoryStringCriterionOption extends CriterionOption {
|
||||
@@ -284,6 +296,10 @@ export class BooleanCriterion extends StringCriterion {
|
||||
protected toCriterionInput(): boolean {
|
||||
return this.value === "true";
|
||||
}
|
||||
|
||||
public isValid() {
|
||||
return this.value === "true" || this.value === "false";
|
||||
}
|
||||
}
|
||||
|
||||
export function createBooleanCriterionOption(
|
||||
@@ -375,7 +391,7 @@ export class NumberCriterion extends Criterion<INumberValue> {
|
||||
protected toCriterionInput(): IntCriterionInput {
|
||||
return {
|
||||
modifier: this.modifier,
|
||||
value: this.value.value,
|
||||
value: this.value.value ?? 0,
|
||||
value2: this.value.value2,
|
||||
};
|
||||
}
|
||||
@@ -392,8 +408,32 @@ export class NumberCriterion extends Criterion<INumberValue> {
|
||||
}
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
if (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { value, value2 } = this.value;
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
value2 === undefined &&
|
||||
(this.modifier === CriterionModifier.Between ||
|
||||
this.modifier === CriterionModifier.NotBetween)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor(type: CriterionOption) {
|
||||
super(type, { value: 0, value2: undefined });
|
||||
super(type, { value: undefined, value2: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +479,17 @@ export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
|
||||
};
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
if (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.value.length > 0;
|
||||
}
|
||||
|
||||
constructor(type: CriterionOption) {
|
||||
super(type, []);
|
||||
}
|
||||
@@ -463,6 +514,17 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
|
||||
return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`;
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
if (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.value.items.length > 0;
|
||||
}
|
||||
|
||||
constructor(type: CriterionOption) {
|
||||
const value: IHierarchicalLabelValue = {
|
||||
items: [],
|
||||
@@ -502,13 +564,13 @@ export function createMandatoryNumberCriterionOption(
|
||||
|
||||
export class DurationCriterion extends Criterion<INumberValue> {
|
||||
constructor(type: CriterionOption) {
|
||||
super(type, { value: 0, value2: undefined });
|
||||
super(type, { value: undefined, value2: undefined });
|
||||
}
|
||||
|
||||
protected toCriterionInput(): IntCriterionInput {
|
||||
return {
|
||||
modifier: this.modifier,
|
||||
value: this.value.value,
|
||||
value: this.value.value ?? 0,
|
||||
value2: this.value.value2,
|
||||
};
|
||||
}
|
||||
@@ -517,15 +579,39 @@ export class DurationCriterion extends Criterion<INumberValue> {
|
||||
return this.modifier === CriterionModifier.Between ||
|
||||
this.modifier === CriterionModifier.NotBetween
|
||||
? `${DurationUtils.secondsToString(
|
||||
this.value.value
|
||||
this.value.value ?? 0
|
||||
)} ${DurationUtils.secondsToString(this.value.value2 ?? 0)}`
|
||||
: this.modifier === CriterionModifier.GreaterThan ||
|
||||
this.modifier === CriterionModifier.LessThan ||
|
||||
this.modifier === CriterionModifier.Equals ||
|
||||
this.modifier === CriterionModifier.NotEquals
|
||||
? DurationUtils.secondsToString(this.value.value)
|
||||
? DurationUtils.secondsToString(this.value.value ?? 0)
|
||||
: "?";
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
if (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { value, value2 } = this.value;
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
value2 === undefined &&
|
||||
(this.modifier === CriterionModifier.Between ||
|
||||
this.modifier === CriterionModifier.NotBetween)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class PhashDuplicateCriterion extends StringCriterion {
|
||||
@@ -592,6 +678,30 @@ export class DateCriterion extends Criterion<IDateValue> {
|
||||
: `${value}`;
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
if (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { value, value2 } = this.value;
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!value2 &&
|
||||
(this.modifier === CriterionModifier.Between ||
|
||||
this.modifier === CriterionModifier.NotBetween)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor(type: CriterionOption) {
|
||||
super(type, { value: "", value2: undefined });
|
||||
}
|
||||
@@ -662,6 +772,30 @@ export class TimestampCriterion extends Criterion<ITimestampValue> {
|
||||
return "";
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
if (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { value, value2 } = this.value;
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!value2 &&
|
||||
(this.modifier === CriterionModifier.Between ||
|
||||
this.modifier === CriterionModifier.NotBetween)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor(type: CriterionOption) {
|
||||
super(type, { value: "", value2: undefined });
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class RatingCriterion extends Criterion<INumberValue> {
|
||||
protected toCriterionInput(): IntCriterionInput {
|
||||
return {
|
||||
modifier: this.modifier,
|
||||
value: this.value.value,
|
||||
value: this.value.value ?? 0,
|
||||
value2: this.value.value2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,4 +103,12 @@ export class StashIDCriterion extends Criterion<IStashIDValue> {
|
||||
}
|
||||
return JSON.stringify(encodedCriterion);
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return (
|
||||
this.modifier === CriterionModifier.IsNull ||
|
||||
this.modifier === CriterionModifier.NotNull ||
|
||||
this.value.stashID.length > 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,16 @@ export class ListFilterModel {
|
||||
return Object.assign(new ListFilterModel(this.mode, this.config), this);
|
||||
}
|
||||
|
||||
// returns the number of filters applied
|
||||
public count() {
|
||||
const count = this.criteria.length;
|
||||
if (this.searchTerm) {
|
||||
return count + 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public configureFromDecodedParams(params: IDecodedParams) {
|
||||
if (params.perPage !== undefined) {
|
||||
this.itemsPerPage = params.perPage;
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface IHierarchicalLabelValue {
|
||||
}
|
||||
|
||||
export interface INumberValue {
|
||||
value: number;
|
||||
value: number | undefined;
|
||||
value2: number | undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user