UI filter builder (#3515)

* Add clear criteria button
* Add count to filter button
This commit is contained in:
WithoutPants
2023-03-16 15:44:46 +11:00
committed by GitHub
parent 7e8f941155
commit 943a6d3be7
27 changed files with 1049 additions and 488 deletions

View File

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

View 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>;
};

View 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>
</>
);
};

View File

@@ -4,7 +4,7 @@ import {
Criterion, Criterion,
CriterionValue, CriterionValue,
} from "src/models/list-filter/criteria/criterion"; } from "src/models/list-filter/criteria/criterion";
import { useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { faTimes } from "@fortawesome/free-solid-svg-icons";
@@ -12,12 +12,14 @@ interface IFilterTagsProps {
criteria: Criterion<CriterionValue>[]; criteria: Criterion<CriterionValue>[];
onEditCriterion: (c: Criterion<CriterionValue>) => void; onEditCriterion: (c: Criterion<CriterionValue>) => void;
onRemoveCriterion: (c: Criterion<CriterionValue>) => void; onRemoveCriterion: (c: Criterion<CriterionValue>) => void;
onRemoveAll: () => void;
} }
export const FilterTags: React.FC<IFilterTagsProps> = ({ export const FilterTags: React.FC<IFilterTagsProps> = ({
criteria, criteria,
onEditCriterion, onEditCriterion,
onRemoveCriterion, onRemoveCriterion,
onRemoveAll,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -55,9 +57,26 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
)); ));
} }
function maybeRenderClearAll() {
if (criteria.length < 3) {
return;
}
return ( return (
<div className="d-flex justify-content-center mb-2 wrap-tags"> <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 filter-tags">
{renderFilterTags()} {renderFilterTags()}
{maybeRenderClearAll()}
</div> </div>
); );
}; };

View 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>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useRef } from "react"; import React from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "../../../core/generated-graphql";
@@ -16,18 +16,17 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const valueStage = useRef<IDateValue>(criterion.value); const { value } = criterion;
function onChanged( function onChanged(
event: React.ChangeEvent<HTMLInputElement>, event: React.ChangeEvent<HTMLInputElement>,
property: "value" | "value2" property: "value" | "value2"
) { ) {
const { value } = event.target; const newValue = event.target.value;
valueStage.current[property] = value; const valueCopy = { ...value };
}
function onBlurInput() { valueCopy[property] = newValue;
onValueChanged(valueStage.current); onValueChanged(valueCopy);
} }
let equalsControl: JSX.Element | null = null; let equalsControl: JSX.Element | null = null;
@@ -43,8 +42,7 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value") onChanged(e, "value")
} }
onBlur={onBlurInput} value={value?.value ?? ""}
defaultValue={criterion.value?.value ?? ""}
placeholder={ placeholder={
intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD)" intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD)"
} }
@@ -67,8 +65,7 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value") onChanged(e, "value")
} }
onBlur={onBlurInput} value={value?.value ?? ""}
defaultValue={criterion.value?.value ?? ""}
placeholder={ placeholder={
intl.formatMessage({ id: "criterion.greater_than" }) + intl.formatMessage({ id: "criterion.greater_than" }) +
" (YYYY-MM-DD)" " (YYYY-MM-DD)"
@@ -97,11 +94,10 @@ export const DateFilter: React.FC<IDateFilterProps> = ({
: "value2" : "value2"
) )
} }
onBlur={onBlurInput} value={
defaultValue={
(criterion.modifier === CriterionModifier.LessThan (criterion.modifier === CriterionModifier.LessThan
? criterion.value?.value ? value?.value
: criterion.value?.value2) ?? "" : value?.value2) ?? ""
} }
placeholder={ placeholder={
intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD)" intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD)"

View 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>
);
};

View File

@@ -80,6 +80,7 @@ export const HierarchicalLabelValueFilter: React.FC<
isMulti isMulti
onSelect={onSelectionChanged} onSelect={onSelectionChanged}
ids={criterion.value.items.map((labeled) => labeled.id)} ids={criterion.value.items.map((labeled) => labeled.id)}
menuPortalTarget={document.body}
/> />
</Form.Group> </Form.Group>

View File

@@ -19,13 +19,15 @@ export const InputFilter: React.FC<IInputFilterProps> = ({
} }
return ( return (
<>
<Form.Group> <Form.Group>
<Form.Control <Form.Control
className="btn-secondary" className="btn-secondary"
type={criterion.criterionOption.inputType} type={criterion.criterionOption.inputType}
onBlur={onChanged} onChange={onChanged}
defaultValue={criterion.value ? criterion.value.toString() : ""} value={criterion.value ? criterion.value.toString() : ""}
/> />
</Form.Group> </Form.Group>
</>
); );
}; };

View File

@@ -42,6 +42,7 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
isMulti isMulti
onSelect={onSelectionChanged} onSelect={onSelectionChanged}
ids={criterion.value.map((labeled) => labeled.id)} ids={criterion.value.map((labeled) => labeled.id)}
menuPortalTarget={document.body}
/> />
</Form.Group> </Form.Group>
); );

View File

@@ -3,10 +3,10 @@ import { Form } from "react-bootstrap";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "../../../core/generated-graphql";
import { INumberValue } from "../../../models/list-filter/types"; 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 { interface IDurationFilterProps {
criterion: Criterion<INumberValue>; criterion: NumberCriterion;
onValueChanged: (value: INumberValue) => void; onValueChanged: (value: INumberValue) => void;
} }
@@ -16,12 +16,14 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { value } = criterion;
function onChanged( function onChanged(
event: React.ChangeEvent<HTMLInputElement>, event: React.ChangeEvent<HTMLInputElement>,
property: "value" | "value2" property: "value" | "value2"
) { ) {
const numericValue = parseInt(event.target.value, 10); const numericValue = parseInt(event.target.value, 10);
const valueCopy = { ...criterion.value }; const valueCopy = { ...value };
valueCopy[property] = !Number.isNaN(numericValue) ? numericValue : 0; valueCopy[property] = !Number.isNaN(numericValue) ? numericValue : 0;
onValueChanged(valueCopy); onValueChanged(valueCopy);
@@ -40,7 +42,7 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value") onChanged(e, "value")
} }
value={criterion.value?.value ?? ""} value={value?.value ?? ""}
placeholder={intl.formatMessage({ id: "criterion.value" })} placeholder={intl.formatMessage({ id: "criterion.value" })}
/> />
</Form.Group> </Form.Group>
@@ -61,7 +63,7 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value") onChanged(e, "value")
} }
value={criterion.value?.value ?? ""} value={value?.value ?? ""}
placeholder={intl.formatMessage({ id: "criterion.greater_than" })} placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
/> />
</Form.Group> </Form.Group>
@@ -89,8 +91,8 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({
} }
value={ value={
(criterion.modifier === CriterionModifier.LessThan (criterion.modifier === CriterionModifier.LessThan
? criterion.value?.value ? value?.value
: criterion.value?.value2) ?? "" : value?.value2) ?? ""
} }
placeholder={intl.formatMessage({ id: "criterion.less_than" })} placeholder={intl.formatMessage({ id: "criterion.less_than" })}
/> />

View File

@@ -1,4 +1,4 @@
import React from "react"; import React, { useMemo } from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { import {
Criterion, Criterion,
@@ -18,16 +18,13 @@ export const OptionsFilter: React.FC<IOptionsFilterProps> = ({
onValueChanged(event.target.value); onValueChanged(event.target.value);
} }
const options = criterion.criterionOption.options ?? []; const options = useMemo(() => {
const ret = criterion.criterionOption.options?.slice() ?? [];
if ( ret.unshift("");
options &&
(criterion.value === undefined || return ret;
criterion.value === "" || }, [criterion.criterionOption.options]);
typeof criterion.value === "number")
) {
onValueChanged(options[0].toString());
}
return ( return (
<Form.Group> <Form.Group>
@@ -39,7 +36,7 @@ export const OptionsFilter: React.FC<IOptionsFilterProps> = ({
> >
{options.map((c) => ( {options.map((c) => (
<option key={c.toString()} value={c.toString()}> <option key={c.toString()} value={c.toString()}>
{c} {c ? c : "---"}
</option> </option>
))} ))}
</Form.Control> </Form.Control>

View 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>
);
};

View File

@@ -15,6 +15,7 @@ export const StashIDFilter: React.FC<IStashIDFilterProps> = ({
onValueChanged, onValueChanged,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { value } = criterion;
function onEndpointChanged(event: React.ChangeEvent<HTMLInputElement>) { function onEndpointChanged(event: React.ChangeEvent<HTMLInputElement>) {
onValueChanged({ onValueChanged({
@@ -35,8 +36,8 @@ export const StashIDFilter: React.FC<IStashIDFilterProps> = ({
<Form.Group> <Form.Group>
<Form.Control <Form.Control
className="btn-secondary" className="btn-secondary"
onBlur={onEndpointChanged} onChange={onEndpointChanged}
defaultValue={criterion.value ? criterion.value.endpoint : ""} value={value ? value.endpoint : ""}
placeholder={intl.formatMessage({ id: "stash_id_endpoint" })} placeholder={intl.formatMessage({ id: "stash_id_endpoint" })}
/> />
</Form.Group> </Form.Group>
@@ -45,8 +46,8 @@ export const StashIDFilter: React.FC<IStashIDFilterProps> = ({
<Form.Group> <Form.Group>
<Form.Control <Form.Control
className="btn-secondary" className="btn-secondary"
onBlur={onStashIDChanged} onChange={onStashIDChanged}
defaultValue={criterion.value ? criterion.value.stashID : ""} value={value ? value.stashID : ""}
placeholder={intl.formatMessage({ id: "stash_id" })} placeholder={intl.formatMessage({ id: "stash_id" })}
/> />
</Form.Group> </Form.Group>

View File

@@ -1,4 +1,4 @@
import React, { useRef } from "react"; import React from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "../../../core/generated-graphql";
@@ -16,18 +16,17 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const valueStage = useRef<ITimestampValue>(criterion.value); const { value } = criterion;
function onChanged( function onChanged(
event: React.ChangeEvent<HTMLInputElement>, event: React.ChangeEvent<HTMLInputElement>,
property: "value" | "value2" property: "value" | "value2"
) { ) {
const { value } = event.target; const newValue = event.target.value;
valueStage.current[property] = value; const valueCopy = { ...value };
}
function onBlurInput() { valueCopy[property] = newValue;
onValueChanged(valueStage.current); onValueChanged(valueCopy);
} }
let equalsControl: JSX.Element | null = null; let equalsControl: JSX.Element | null = null;
@@ -43,11 +42,10 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value") onChanged(e, "value")
} }
onBlur={onBlurInput} value={value?.value ?? ""}
defaultValue={criterion.value?.value ?? ""}
placeholder={ placeholder={
intl.formatMessage({ id: "criterion.value" }) + intl.formatMessage({ id: "criterion.value" }) +
" (YYYY-MM-DD HH-MM)" " (YYYY-MM-DD HH:MM)"
} }
/> />
</Form.Group> </Form.Group>
@@ -68,11 +66,10 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value") onChanged(e, "value")
} }
onBlur={onBlurInput} value={value?.value ?? ""}
defaultValue={criterion.value?.value ?? ""}
placeholder={ placeholder={
intl.formatMessage({ id: "criterion.greater_than" }) + intl.formatMessage({ id: "criterion.greater_than" }) +
" (YYYY-MM-DD HH-MM)" " (YYYY-MM-DD HH:MM)"
} }
/> />
</Form.Group> </Form.Group>
@@ -98,15 +95,14 @@ export const TimestampFilter: React.FC<ITimestampFilterProps> = ({
: "value2" : "value2"
) )
} }
onBlur={onBlurInput} value={
defaultValue={
(criterion.modifier === CriterionModifier.LessThan (criterion.modifier === CriterionModifier.LessThan
? criterion.value?.value ? value?.value
: criterion.value?.value2) ?? "" : value?.value2) ?? ""
} }
placeholder={ placeholder={
intl.formatMessage({ id: "criterion.less_than" }) + intl.formatMessage({ id: "criterion.less_than" }) +
" (YYYY-MM-DD HH-MM)" " (YYYY-MM-DD HH:MM)"
} }
/> />
</Form.Group> </Form.Group>

View File

@@ -25,7 +25,7 @@ import { ConfigurationContext } from "src/hooks/Config";
import { getFilterOptions } from "src/models/list-filter/factory"; import { getFilterOptions } from "src/models/list-filter/factory";
import { useFindDefaultFilter } from "src/core/StashService"; import { useFindDefaultFilter } from "src/core/StashService";
import { Pagination, PaginationIndex } from "./Pagination"; import { Pagination, PaginationIndex } from "./Pagination";
import { AddFilterDialog } from "./AddFilterDialog"; import { EditFilterDialog } from "src/components/List/EditFilterDialog";
import { ListFilter } from "./ListFilter"; import { ListFilter } from "./ListFilter";
import { FilterTags } from "./FilterTags"; import { FilterTags } from "./FilterTags";
import { ListViewOptions } from "./ListViewOptions"; import { ListViewOptions } from "./ListViewOptions";
@@ -140,7 +140,6 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
onChangePage: _onChangePage, onChangePage: _onChangePage,
updateFilter, updateFilter,
persistState, persistState,
filterDialog,
zoomable, zoomable,
selectable, selectable,
otherOperations, otherOperations,
@@ -154,9 +153,10 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>(); const [lastClickedId, setLastClickedId] = useState<string>();
const [editingCriterion, setEditingCriterion] = const [editingCriterion, setEditingCriterion] = useState<
useState<Criterion<CriterionValue>>(); string | undefined
const [newCriterion, setNewCriterion] = useState(false); >();
const [showEditFilter, setShowEditFilter] = useState(false);
const result = useResult(filter); const result = useResult(filter);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
@@ -193,7 +193,7 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
Mousetrap.bind("f", () => setNewCriterion(true)); Mousetrap.bind("f", () => setShowEditFilter(true));
return () => { return () => {
Mousetrap.unbind("f"); Mousetrap.unbind("f");
@@ -432,40 +432,6 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
updateFilter(newFilter); 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>) { function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
const newFilter = cloneDeep(filter); const newFilter = cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter( newFilter.criteria = newFilter.criteria.filter(
@@ -475,10 +441,22 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
updateFilter(newFilter); updateFilter(newFilter);
} }
function updateCriteria(c: Criterion<CriterionValue>[]) { function onClearAllCriteria() {
const newFilter = cloneDeep(filter); const newFilter = cloneDeep(filter);
newFilter.criteria = c.slice(); newFilter.criteria = [];
setNewCriterion(false); newFilter.currentPage = 1;
updateFilter(newFilter);
}
function onApplyEditFilter(f: ListFilterModel) {
setShowEditFilter(false);
setEditingCriterion(undefined);
updateFilter(f);
}
function onCancelEditFilter() {
setShowEditFilter(false);
setEditingCriterion(undefined);
} }
return ( return (
@@ -488,8 +466,7 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
onFilterUpdate={updateFilter} onFilterUpdate={updateFilter}
filter={filter} filter={filter}
filterOptions={filterOptions} filterOptions={filterOptions}
openFilterDialog={() => setNewCriterion(true)} openFilterDialog={() => setShowEditFilter(true)}
filterDialogOpen={newCriterion}
persistState={persistState} persistState={persistState}
/> />
<ListOperationButtons <ListOperationButtons
@@ -510,21 +487,18 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
</ButtonToolbar> </ButtonToolbar>
<FilterTags <FilterTags
criteria={filter.criteria} criteria={filter.criteria}
onEditCriterion={(c) => setEditingCriterion(c)} onEditCriterion={(c) => setEditingCriterion(c.criterionOption.type)}
onRemoveCriterion={onRemoveCriterion} onRemoveCriterion={onRemoveCriterion}
onRemoveAll={() => onClearAllCriteria()}
/> />
{(newCriterion || editingCriterion) && !filterDialog && ( {(showEditFilter || editingCriterion) && (
<AddFilterDialog <EditFilterDialog
filterOptions={filterOptions} filter={filter}
onAddCriterion={onAddCriterion} onApply={onApplyEditFilter}
onCancel={onCancelAddCriterion} onCancel={onCancelEditFilter}
editingCriterion={editingCriterion} editingCriterion={editingCriterion}
existingCriterions={filter.criteria}
/> />
)} )}
{newCriterion &&
filterDialog &&
filterDialog(filter.criteria, (c) => updateCriteria(c))}
{isEditDialogOpen && {isEditDialogOpen &&
renderEditDialog && renderEditDialog &&
renderEditDialog(getSelectedData(items, selectedIds), (applied) => renderEditDialog(getSelectedData(items, selectedIds), (applied) =>

View File

@@ -34,17 +34,16 @@ import {
faCaretDown, faCaretDown,
faCaretUp, faCaretUp,
faCheck, faCheck,
faFilter,
faRandom, faRandom,
faTimes, faTimes,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FilterButton } from "./Filters/FilterButton";
import { useDebounce } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
interface IListFilterProps { interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void; onFilterUpdate: (newFilter: ListFilterModel) => void;
filter: ListFilterModel; filter: ListFilterModel;
filterOptions: ListFilterOptions; filterOptions: ListFilterOptions;
filterDialogOpen?: boolean;
persistState?: PersistanceLevel; persistState?: PersistanceLevel;
openFilterDialog: () => void; openFilterDialog: () => void;
} }
@@ -55,7 +54,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
onFilterUpdate, onFilterUpdate,
filter, filter,
filterOptions, filterOptions,
filterDialogOpen,
openFilterDialog, openFilterDialog,
persistState, persistState,
}) => { }) => {
@@ -289,13 +287,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
</Tooltip> </Tooltip>
} }
> >
<Button <FilterButton onClick={() => openFilterDialog()} filter={filter} />
variant="secondary"
onClick={() => openFilterDialog()}
active={filterDialogOpen}
>
<Icon icon={faFilter} />
</Button>
</OverlayTrigger> </OverlayTrigger>
</ButtonGroup> </ButtonGroup>

View File

@@ -116,3 +116,83 @@ input[type="range"].zoom-slider {
.rating-filter .and-divider { .rating-filter .and-divider {
margin-left: 0.5em; 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;
}
}

View File

@@ -14,6 +14,7 @@
* Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369)) * Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369))
### 🎨 Improvements ### 🎨 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)) * 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)) * 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)) * Improved Images wall view layout and added Interface settings to adjust the layout. ([#3511](https://github.com/stashapp/stash/pull/3511))

View File

@@ -32,3 +32,18 @@ export function useInitialState<T>(
return [value, setValue, setInitialValue]; 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;
}

View File

@@ -286,11 +286,15 @@ div.react-select__control {
} }
} }
div.react-select__menu-portal {
z-index: 1600;
}
div.react-select__menu, div.react-select__menu,
div.dropdown-menu { div.dropdown-menu {
background-color: $secondary; background-color: $secondary;
color: $text-color; color: $text-color;
z-index: 16; z-index: 1600;
.react-select__option, .react-select__option,
.dropdown-item { .dropdown-item {

View File

@@ -1039,7 +1039,7 @@
"scenes": "Scenes", "scenes": "Scenes",
"scenes_updated_at": "Scene Updated At", "scenes_updated_at": "Scene Updated At",
"search_filter": { "search_filter": {
"add_filter": "Add Filter", "edit_filter": "Edit Filter",
"name": "Filter", "name": "Filter",
"saved_filters": "Saved filters", "saved_filters": "Saved filters",
"update_filter": "Update Filter" "update_filter": "Update Filter"

View File

@@ -70,6 +70,10 @@ export abstract class Criterion<V extends CriterionValue> {
this._value = newValue; this._value = newValue;
} }
public isValid(): boolean {
return true;
}
public abstract getLabelValue(intl: IntlShape): string; public abstract getLabelValue(intl: IntlShape): string;
constructor(type: CriterionOption, value: V) { constructor(type: CriterionOption, value: V) {
@@ -227,6 +231,14 @@ export class StringCriterion extends Criterion<string> {
public getLabelValue(_intl: IntlShape) { public getLabelValue(_intl: IntlShape) {
return this.value; return this.value;
} }
public isValid(): boolean {
return (
this.modifier === CriterionModifier.IsNull ||
this.modifier === CriterionModifier.NotNull ||
this.value.length > 0
);
}
} }
export class MandatoryStringCriterionOption extends CriterionOption { export class MandatoryStringCriterionOption extends CriterionOption {
@@ -284,6 +296,10 @@ export class BooleanCriterion extends StringCriterion {
protected toCriterionInput(): boolean { protected toCriterionInput(): boolean {
return this.value === "true"; return this.value === "true";
} }
public isValid() {
return this.value === "true" || this.value === "false";
}
} }
export function createBooleanCriterionOption( export function createBooleanCriterionOption(
@@ -375,7 +391,7 @@ export class NumberCriterion extends Criterion<INumberValue> {
protected toCriterionInput(): IntCriterionInput { protected toCriterionInput(): IntCriterionInput {
return { return {
modifier: this.modifier, modifier: this.modifier,
value: this.value.value, value: this.value.value ?? 0,
value2: this.value.value2, 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) { 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) { constructor(type: CriterionOption) {
super(type, []); super(type, []);
} }
@@ -463,6 +514,17 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`; 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) { constructor(type: CriterionOption) {
const value: IHierarchicalLabelValue = { const value: IHierarchicalLabelValue = {
items: [], items: [],
@@ -502,13 +564,13 @@ export function createMandatoryNumberCriterionOption(
export class DurationCriterion extends Criterion<INumberValue> { export class DurationCriterion extends Criterion<INumberValue> {
constructor(type: CriterionOption) { constructor(type: CriterionOption) {
super(type, { value: 0, value2: undefined }); super(type, { value: undefined, value2: undefined });
} }
protected toCriterionInput(): IntCriterionInput { protected toCriterionInput(): IntCriterionInput {
return { return {
modifier: this.modifier, modifier: this.modifier,
value: this.value.value, value: this.value.value ?? 0,
value2: this.value.value2, value2: this.value.value2,
}; };
} }
@@ -517,15 +579,39 @@ export class DurationCriterion extends Criterion<INumberValue> {
return this.modifier === CriterionModifier.Between || return this.modifier === CriterionModifier.Between ||
this.modifier === CriterionModifier.NotBetween this.modifier === CriterionModifier.NotBetween
? `${DurationUtils.secondsToString( ? `${DurationUtils.secondsToString(
this.value.value this.value.value ?? 0
)} ${DurationUtils.secondsToString(this.value.value2 ?? 0)}` )} ${DurationUtils.secondsToString(this.value.value2 ?? 0)}`
: this.modifier === CriterionModifier.GreaterThan || : this.modifier === CriterionModifier.GreaterThan ||
this.modifier === CriterionModifier.LessThan || this.modifier === CriterionModifier.LessThan ||
this.modifier === CriterionModifier.Equals || this.modifier === CriterionModifier.Equals ||
this.modifier === CriterionModifier.NotEquals 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 { export class PhashDuplicateCriterion extends StringCriterion {
@@ -592,6 +678,30 @@ export class DateCriterion extends Criterion<IDateValue> {
: `${value}`; : `${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) { constructor(type: CriterionOption) {
super(type, { value: "", value2: undefined }); super(type, { value: "", value2: undefined });
} }
@@ -662,6 +772,30 @@ export class TimestampCriterion extends Criterion<ITimestampValue> {
return ""; 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) { constructor(type: CriterionOption) {
super(type, { value: "", value2: undefined }); super(type, { value: "", value2: undefined });
} }

View File

@@ -31,7 +31,7 @@ export class RatingCriterion extends Criterion<INumberValue> {
protected toCriterionInput(): IntCriterionInput { protected toCriterionInput(): IntCriterionInput {
return { return {
modifier: this.modifier, modifier: this.modifier,
value: this.value.value, value: this.value.value ?? 0,
value2: this.value.value2, value2: this.value.value2,
}; };
} }

View File

@@ -103,4 +103,12 @@ export class StashIDCriterion extends Criterion<IStashIDValue> {
} }
return JSON.stringify(encodedCriterion); return JSON.stringify(encodedCriterion);
} }
public isValid(): boolean {
return (
this.modifier === CriterionModifier.IsNull ||
this.modifier === CriterionModifier.NotNull ||
this.value.stashID.length > 0
);
}
} }

View File

@@ -75,6 +75,16 @@ export class ListFilterModel {
return Object.assign(new ListFilterModel(this.mode, this.config), this); 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) { public configureFromDecodedParams(params: IDecodedParams) {
if (params.perPage !== undefined) { if (params.perPage !== undefined) {
this.itemsPerPage = params.perPage; this.itemsPerPage = params.perPage;

View File

@@ -24,7 +24,7 @@ export interface IHierarchicalLabelValue {
} }
export interface INumberValue { export interface INumberValue {
value: number; value: number | undefined;
value2: number | undefined; value2: number | undefined;
} }