1
0
Fork 0
mirror of https://github.com/Sonarr/Sonarr synced 2025-03-09 21:56:45 +00:00

Convert Filter components to TypeScript

This commit is contained in:
Mark McDowall 2024-12-21 16:28:05 -08:00
parent e4f1b2c4ec
commit 3cd8a2a98b
No known key found for this signature in database
55 changed files with 1644 additions and 1840 deletions

View file

@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
function createBlocklistSelector() {
@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
);
}
interface BlocklistFilterModalProps {
isOpen: boolean;
}
type BlocklistFilterModalProps = FilterModalProps<History>;
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
const sectionItems = useSelector(createBlocklistSelector());
@ -43,7 +41,6 @@ export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}

View file

@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setHistoryFilter } from 'Store/Actions/historyActions';
function createHistorySelector() {
@ -23,19 +23,16 @@ function createFilterBuilderPropsSelector() {
);
}
interface HistoryFilterModalProps {
isOpen: boolean;
}
type HistoryFilterModalProps = FilterModalProps<History>;
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'history';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
(payload: { selectedFilterKey: string | number }) => {
dispatch(setHistoryFilter(payload));
},
[dispatch]
@ -43,11 +40,10 @@ export default function HistoryFilterModal(props: HistoryFilterModalProps) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
customFilterType="history"
dispatchSetFilter={dispatchSetFilter}
/>
);

View file

@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions';
function createQueueSelector() {
@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
);
}
interface QueueFilterModalProps {
isOpen: boolean;
}
type QueueFilterModalProps = FilterModalProps<History>;
export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector());
@ -43,7 +41,6 @@ export default function QueueFilterModal(props: QueueFilterModalProps) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}

View file

@ -1,3 +1,6 @@
import ModelBase from 'App/ModelBase';
import { DateFilterValue } from 'Components/Filter/Builder/DateFilterBuilderRowValue';
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
@ -21,33 +24,33 @@ import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
interface FilterBuilderPropOption {
export interface FilterBuilderPropOption {
id: string;
name: string;
}
export interface FilterBuilderProp<T> {
name: string;
label: string;
type: string;
label: string | (() => string);
type: FilterBuilderTypes;
valueType?: string;
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
}
export interface PropertyFilter {
key: string;
value: boolean | string | number | string[] | number[];
value: string | string[] | number[] | boolean[] | DateFilterValue;
type: string;
}
export interface Filter {
key: string;
label: string;
type: string;
filters: PropertyFilter[];
}
export interface CustomFilter {
id: number;
export interface CustomFilter extends ModelBase {
type: string;
label: string;
filters: PropertyFilter[];

View file

@ -1,10 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import { CustomFilter } from './AppState';
interface CustomFiltersAppState
extends AppSectionState<CustomFilter>,
AppSectionDeleteState {}
AppSectionDeleteState,
AppSectionSaveState {}
export default CustomFiltersAppState;

View file

@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setCalendarFilter } from 'Store/Actions/calendarActions';
function createCalendarSelector() {
@ -23,9 +23,7 @@ function createFilterBuilderPropsSelector() {
);
}
interface CalendarFilterModalProps {
isOpen: boolean;
}
type CalendarFilterModalProps = FilterModalProps<History>;
export default function CalendarFilterModal(props: CalendarFilterModalProps) {
const sectionItems = useSelector(createCalendarSelector());
@ -43,7 +41,6 @@ export default function CalendarFilterModal(props: CalendarFilterModalProps) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}

View file

@ -1,18 +0,0 @@
import React from 'react';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const protocols = [
{ id: true, name: 'true' },
{ id: false, name: 'false' }
];
function BoolFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={protocols}
{...props}
/>
);
}
export default BoolFilterBuilderRowValue;

View file

@ -0,0 +1,22 @@
import React from 'react';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
const protocols = [
{ id: true, name: 'true' },
{ id: false, name: 'false' },
];
type BoolFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, boolean>,
'tagList'
>;
function BoolFilterBuilderRowValue<T>(
props: BoolFilterBuilderRowValueProps<T>
) {
return <FilterBuilderRowValue tagList={protocols} {...props} />;
}
export default BoolFilterBuilderRowValue;

View file

@ -1,177 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import NumberInput from 'Components/Form/NumberInput';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import { IN_LAST, IN_NEXT, NOT_IN_LAST, NOT_IN_NEXT } from 'Helpers/Props/filterTypes';
import isString from 'Utilities/String/isString';
import { NAME } from './FilterBuilderRowValue';
import styles from './DateFilterBuilderRowValue.css';
const timeOptions = [
{ key: 'seconds', value: 'seconds' },
{ key: 'minutes', value: 'minutes' },
{ key: 'hours', value: 'hours' },
{ key: 'days', value: 'days' },
{ key: 'weeks', value: 'weeks' },
{ key: 'months', value: 'months' }
];
function isInFilter(filterType) {
return (
filterType === IN_LAST ||
filterType === NOT_IN_LAST ||
filterType === IN_NEXT ||
filterType === NOT_IN_NEXT
);
}
class DateFilterBuilderRowValue extends Component {
//
// Lifecycle
componentDidMount() {
const {
filterType,
filterValue,
onChange
} = this.props;
if (isInFilter(filterType) && isString(filterValue)) {
onChange({
name: NAME,
value: {
time: timeOptions[0].key,
value: null
}
});
}
}
componentDidUpdate(prevProps) {
const {
filterType,
filterValue,
onChange
} = this.props;
if (prevProps.filterType === filterType) {
return;
}
if (isInFilter(filterType) && isString(filterValue)) {
onChange({
name: NAME,
value: {
time: timeOptions[0].key,
value: null
}
});
return;
}
if (!isInFilter(filterType) && !isString(filterValue)) {
onChange({
name: NAME,
value: ''
});
}
}
//
// Listeners
onValueChange = ({ value }) => {
const {
filterValue,
onChange
} = this.props;
let newValue = value;
if (!isString(value)) {
newValue = {
time: filterValue.time,
value
};
}
onChange({
name: NAME,
value: newValue
});
};
onTimeChange = ({ value }) => {
const {
filterValue,
onChange
} = this.props;
onChange({
name: NAME,
value: {
time: value,
value: filterValue.value
}
});
};
//
// Render
render() {
const {
filterType,
filterValue
} = this.props;
if (
(isInFilter(filterType) && isString(filterValue)) ||
(!isInFilter(filterType) && !isString(filterValue))
) {
return null;
}
if (isInFilter(filterType)) {
return (
<div className={styles.container}>
<NumberInput
className={styles.numberInput}
name={NAME}
value={filterValue.value}
onChange={this.onValueChange}
/>
<SelectInput
className={styles.selectInput}
name={NAME}
value={filterValue.time}
values={timeOptions}
onChange={this.onTimeChange}
/>
</div>
);
}
return (
<TextInput
name={NAME}
value={filterValue}
type="date"
placeholder="yyyy-mm-dd"
onChange={this.onValueChange}
/>
);
}
}
DateFilterBuilderRowValue.propTypes = {
filterType: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
onChange: PropTypes.func.isRequired
};
export default DateFilterBuilderRowValue;

View file

@ -0,0 +1,158 @@
import React, { useCallback, useEffect } from 'react';
import NumberInput from 'Components/Form/NumberInput';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
IN_LAST,
IN_NEXT,
NOT_IN_LAST,
NOT_IN_NEXT,
} from 'Helpers/Props/filterTypes';
import { InputChanged, InputOnChange } from 'typings/inputs';
import isString from 'Utilities/String/isString';
import { FilterBuilderRowValueProps, NAME } from './FilterBuilderRowValue';
import styles from './DateFilterBuilderRowValue.css';
const timeOptions = [
{ key: 'seconds', value: 'seconds' },
{ key: 'minutes', value: 'minutes' },
{ key: 'hours', value: 'hours' },
{ key: 'days', value: 'days' },
{ key: 'weeks', value: 'weeks' },
{ key: 'months', value: 'months' },
];
function isInFilter(filterType: string | undefined) {
return (
filterType === IN_LAST ||
filterType === NOT_IN_LAST ||
filterType === IN_NEXT ||
filterType === NOT_IN_NEXT
);
}
export interface DateFilterValue {
time: string;
value: number | null;
}
interface DateFilterBuilderRowValueProps<T>
extends Omit<
FilterBuilderRowValueProps<T, string>,
'filterValue' | 'onChange'
> {
filterValue: string | DateFilterValue;
onChange: InputOnChange<string | DateFilterValue>;
}
function DateFilterBuilderRowValue<T>({
filterType,
filterValue,
onChange,
}: DateFilterBuilderRowValueProps<T>) {
const previousFilterType = usePrevious(filterType);
const handleValueChange = useCallback(
({ value }: InputChanged<number | null>) => {
onChange({
name: NAME,
value: {
time: (filterValue as DateFilterValue).time,
value,
},
});
},
[filterValue, onChange]
);
const handleTimeChange = useCallback(
({ value }: InputChanged<string>) => {
onChange({
name: NAME,
value: {
time: value,
value: (filterValue as DateFilterValue).value,
},
});
},
[filterValue, onChange]
);
const handleDateChange = useCallback(
({ value }: InputChanged<string>) => {
onChange({
name: NAME,
value,
});
},
[onChange]
);
useEffect(() => {
if (previousFilterType === filterType) {
return;
}
if (isInFilter(filterType) && isString(filterValue)) {
onChange({
name: NAME,
value: {
time: timeOptions[0].key,
value: null,
},
});
return;
}
if (!isInFilter(filterType) && !isString(filterValue)) {
onChange({
name: NAME,
value: '',
});
}
}, [filterType, previousFilterType, filterValue, onChange]);
if (
(isInFilter(filterType) && isString(filterValue)) ||
(!isInFilter(filterType) && !isString(filterValue))
) {
return null;
}
if (isInFilter(filterType)) {
const { value, time } = filterValue as DateFilterValue;
return (
<div className={styles.container}>
<NumberInput
className={styles.numberInput}
name={NAME}
value={value}
onChange={handleValueChange}
/>
<SelectInput
className={styles.selectInput}
name={NAME}
value={time}
values={timeOptions}
onChange={handleTimeChange}
/>
</div>
);
}
return (
<TextInput
name={NAME}
value={filterValue as string}
type="date"
placeholder="yyyy-mm-dd"
onChange={handleDateChange}
/>
);
}
export default DateFilterBuilderRowValue;

View file

@ -0,0 +1,74 @@
import React, { useMemo } from 'react';
import { FilterBuilderPropOption } from 'App/State/AppState';
import { filterBuilderTypes } from 'Helpers/Props';
import * as filterTypes from 'Helpers/Props/filterTypes';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
type DefaultFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
'tagList'
>;
function DefaultFilterBuilderRowValue<T>({
filterType,
sectionItems,
selectedFilterBuilderProp,
...otherProps
}: DefaultFilterBuilderRowValueProps<T>) {
const tagList = useMemo(() => {
if (
((selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ||
selectedFilterBuilderProp.type === filterBuilderTypes.STRING) &&
filterType !== filterTypes.EQUAL &&
filterType !== filterTypes.NOT_EQUAL) ||
!selectedFilterBuilderProp.optionsSelector
) {
return [];
}
let items: FilterBuilderPropOption[] = [];
if (selectedFilterBuilderProp.optionsSelector) {
items = selectedFilterBuilderProp
.optionsSelector(sectionItems)
.filter(
(value, index, array) =>
array.findIndex((v) => v.id === value.id) === index
);
} else {
items = sectionItems
.reduce<FilterBuilderPropOption[]>((acc, item) => {
// @ts-expect-error - can't guarantee that the name property exists on the item
const name = item[selectedFilterBuilderProp.name];
// DOn't add invalid values or items that already exist
if (name && acc.findIndex((a) => a.id === name) === -1) {
acc.push({
id: name,
name,
});
}
return acc;
}, [])
.sort(sortByProp('name'));
}
return items;
}, [filterType, sectionItems, selectedFilterBuilderProp]);
return (
<FilterBuilderRowValue
{...otherProps}
filterType={filterType}
sectionItems={sectionItems}
selectedFilterBuilderProp={selectedFilterBuilderProp}
tagList={tagList}
/>
);
}
export default DefaultFilterBuilderRowValue;

View file

@ -1,232 +0,0 @@
import { maxBy } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FilterBuilderRow from './FilterBuilderRow';
import styles from './FilterBuilderModalContent.css';
class FilterBuilderModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const filters = [...props.filters];
// Push an empty filter if there aren't any filters. FilterBuilderRow
// will handle initializing the filter.
if (!filters.length) {
filters.push({});
}
this.state = {
label: props.label,
filters,
labelErrors: []
};
}
componentDidUpdate(prevProps) {
const {
id,
customFilters,
isSaving,
saveError,
dispatchSetFilter,
onModalClose
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
if (id) {
dispatchSetFilter({ selectedFilterKey: id });
} else {
const last = maxBy(customFilters, 'id');
dispatchSetFilter({ selectedFilterKey: last.id });
}
onModalClose();
}
}
//
// Listeners
onLabelChange = ({ value }) => {
this.setState({ label: value });
};
onFilterChange = (index, filter) => {
const filters = [...this.state.filters];
filters.splice(index, 1, filter);
this.setState({
filters
});
};
onAddFilterPress = () => {
const filters = [...this.state.filters];
filters.push({});
this.setState({
filters
});
};
onRemoveFilterPress = (index) => {
const filters = [...this.state.filters];
filters.splice(index, 1);
this.setState({
filters
});
};
onSaveFilterPress = () => {
const {
id,
customFilterType,
onSaveCustomFilterPress
} = this.props;
const {
label,
filters
} = this.state;
if (!label) {
this.setState({
labelErrors: [
{
message: translate('LabelIsRequired')
}
]
});
return;
}
onSaveCustomFilterPress({
id,
type: customFilterType,
label,
filters
});
};
//
// Render
render() {
const {
sectionItems,
filterBuilderProps,
isSaving,
saveError,
onCancelPress,
onModalClose
} = this.props;
const {
label,
filters,
labelErrors
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CustomFilter')}
</ModalHeader>
<ModalBody>
<div className={styles.labelContainer}>
<div className={styles.label}>
{translate('Label')}
</div>
<div className={styles.labelInputContainer}>
<FormInputGroup
name="label"
value={label}
type={inputTypes.TEXT}
errors={labelErrors}
onChange={this.onLabelChange}
/>
</div>
</div>
<div className={styles.label}>
{translate('Filters')}
</div>
<div className={styles.rows}>
{
filters.map((filter, index) => {
return (
<FilterBuilderRow
key={`${filter.key}-${index}`}
index={index}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
filterKey={filter.key}
filterValue={filter.value}
filterType={filter.type}
filterCount={filters.length}
onAddPress={this.onAddFilterPress}
onRemovePress={this.onRemoveFilterPress}
onFilterChange={this.onFilterChange}
/>
);
})
}
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onCancelPress}>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={this.onSaveFilterPress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
}
FilterBuilderModalContent.propTypes = {
id: PropTypes.number,
label: PropTypes.string.isRequired,
customFilterType: PropTypes.string.isRequired,
sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
dispatchDeleteCustomFilter: PropTypes.func.isRequired,
onSaveCustomFilterPress: PropTypes.func.isRequired,
dispatchSetFilter: PropTypes.func.isRequired,
onCancelPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default FilterBuilderModalContent;

View file

@ -0,0 +1,208 @@
import { maxBy } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState, {
CustomFilter,
FilterBuilderProp,
PropertyFilter,
} from 'App/State/AppState';
import FormInputGroup, {
ValidationMessage,
} from 'Components/Form/FormInputGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes } from 'Helpers/Props';
import { saveCustomFilter } from 'Store/Actions/customFilterActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import FilterBuilderRow from './FilterBuilderRow';
import styles from './FilterBuilderModalContent.css';
const NEW_FILTER: Partial<PropertyFilter> = {
key: undefined,
value: undefined,
type: '',
};
interface FilterBuilderModalContentProps<T> {
id: number | null;
customFilterType: string;
sectionItems: T[];
filterBuilderProps: FilterBuilderProp<T>[];
customFilters: CustomFilter[];
dispatchSetFilter: (payload: { selectedFilterKey: string | number }) => void;
onCancelPress: () => void;
onModalClose: () => void;
}
function FilterBuilderModalContent<T>({
id,
customFilters,
customFilterType,
sectionItems,
filterBuilderProps,
dispatchSetFilter,
onCancelPress,
onModalClose,
}: FilterBuilderModalContentProps<T>) {
const dispatch = useDispatch();
const { isSaving, saveError } = useSelector(
(state: AppState) => state.customFilters
);
const { initialLabel, initialFilters } = useMemo(() => {
if (id) {
const customFilter = customFilters.find((c) => c.id === id);
if (customFilter) {
return {
initialLabel: customFilter.label,
initialFilters: customFilter.filters,
};
}
}
return {
initialLabel: '',
initialFilters: [],
};
}, [id, customFilters]);
const [label, setLabel] = useState(initialLabel);
// Push an empty filter if there aren't any filters. FilterBuilderRow
// will handle initializing the filter.
const [filters, setFilters] = useState<Partial<PropertyFilter>[]>(
initialFilters.length ? initialFilters : [NEW_FILTER]
);
const [labelErrors, setLabelErrors] = useState<ValidationMessage[]>([]);
const wasSaving = usePrevious(isSaving);
const handleLabelChange = useCallback(({ value }: InputChanged<string>) => {
setLabel(value);
}, []);
const handleFilterChange = useCallback(
(index: number, filter: PropertyFilter) => {
const newFilters = [...filters];
newFilters.splice(index, 1, filter);
setFilters(newFilters);
},
[filters]
);
const handleAddFilterPress = useCallback(() => {
setFilters([...filters, NEW_FILTER]);
}, [filters]);
const handleRemoveFilterPress = useCallback(
(index: number) => {
const newFilters = [...filters];
newFilters.splice(index, 1);
setFilters(newFilters);
},
[filters]
);
const handleSaveFilterPress = useCallback(() => {
if (!label) {
setLabelErrors([
{
message: translate('LabelIsRequired'),
},
]);
return;
}
dispatch(saveCustomFilter({ id, type: customFilterType, label, filters }));
}, [id, customFilterType, label, filters, dispatch]);
useEffect(() => {
if (wasSaving && !isSaving && !saveError) {
if (id) {
dispatchSetFilter({ selectedFilterKey: id });
} else {
const last = maxBy(customFilters, 'id');
if (last) {
dispatchSetFilter({ selectedFilterKey: last.id });
}
}
onModalClose();
}
}, [
id,
customFilters,
isSaving,
wasSaving,
saveError,
dispatchSetFilter,
onModalClose,
]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('CustomFilter')}</ModalHeader>
<ModalBody>
<div className={styles.labelContainer}>
<div className={styles.label}>{translate('Label')}</div>
<div className={styles.labelInputContainer}>
<FormInputGroup
name="label"
value={label}
type={inputTypes.TEXT}
errors={labelErrors}
onChange={handleLabelChange}
/>
</div>
</div>
<div className={styles.label}>{translate('Filters')}</div>
<div className={styles.rows}>
{filters.map((filter, index) => {
return (
<FilterBuilderRow
key={`${filter.key}-${index}`}
index={index}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
filterKey={filter.key}
filterValue={filter.value}
filterType={filter.type}
filterCount={filters.length}
onAddPress={handleAddFilterPress}
onRemovePress={handleRemoveFilterPress}
onFilterChange={handleFilterChange}
/>
);
})}
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onCancelPress}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={handleSaveFilterPress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default FilterBuilderModalContent;

View file

@ -1,42 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteCustomFilter, saveCustomFilter } from 'Store/Actions/customFilterActions';
import FilterBuilderModalContent from './FilterBuilderModalContent';
function createMapStateToProps() {
return createSelector(
(state, { customFilters }) => customFilters,
(state, { id }) => id,
(state) => state.customFilters.isSaving,
(state) => state.customFilters.saveError,
(customFilters, id, isSaving, saveError) => {
if (id) {
const customFilter = customFilters.find((c) => c.id === id);
return {
id: customFilter.id,
label: customFilter.label,
filters: customFilter.filters,
customFilters,
isSaving,
saveError
};
}
return {
label: '',
filters: [],
customFilters,
isSaving,
saveError
};
}
);
}
const mapDispatchToProps = {
onSaveCustomFilterPress: saveCustomFilter,
dispatchDeleteCustomFilter: deleteCustomFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent);

View file

@ -1,309 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import IconButton from 'Components/Link/IconButton';
import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
import sortByProp from 'Utilities/Array/sortByProp';
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
import styles from './FilterBuilderRow.css';
function getselectedFilterBuilderProp(filterBuilderProps, name) {
return filterBuilderProps.find((a) => {
return a.name === name;
});
}
function getFilterTypeOptions(filterBuilderProps, filterKey) {
const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey);
if (!selectedFilterBuilderProp) {
return [];
}
return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type];
}
function getDefaultFilterType(selectedFilterBuilderProp) {
return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key;
}
function getDefaultFilterValue(selectedFilterBuilderProp) {
if (selectedFilterBuilderProp.type === filterBuilderTypes.DATE) {
return '';
}
return [];
}
function getRowValueConnector(selectedFilterBuilderProp) {
if (!selectedFilterBuilderProp) {
return FilterBuilderRowValueConnector;
}
const valueType = selectedFilterBuilderProp.valueType;
switch (valueType) {
case filterBuilderValueTypes.BOOL:
return BoolFilterBuilderRowValue;
case filterBuilderValueTypes.DATE:
return DateFilterBuilderRowValue;
case filterBuilderValueTypes.HISTORY_EVENT_TYPE:
return HistoryEventTypeFilterBuilderRowValue;
case filterBuilderValueTypes.INDEXER:
return IndexerFilterBuilderRowValueConnector;
case filterBuilderValueTypes.LANGUAGE:
return LanguageFilterBuilderRowValue;
case filterBuilderValueTypes.PROTOCOL:
return ProtocolFilterBuilderRowValue;
case filterBuilderValueTypes.QUALITY:
return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue;
case filterBuilderValueTypes.QUEUE_STATUS:
return QueueStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES:
return SeriesFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES_STATUS:
return SeriesStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES_TYPES:
return SeriesTypeFilterBuilderRowValue;
case filterBuilderValueTypes.TAG:
return TagFilterBuilderRowValueConnector;
default:
return FilterBuilderRowValueConnector;
}
}
class FilterBuilderRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
filterKey,
filterBuilderProps
} = props;
if (filterKey) {
const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey);
this.selectedFilterBuilderProp = selectedFilterBuilderProp;
}
}
componentDidMount() {
const {
index,
filterKey,
filterBuilderProps,
onFilterChange
} = this.props;
if (filterKey) {
const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey);
this.selectedFilterBuilderProp = selectedFilterBuilderProp;
return;
}
const selectedFilterBuilderProp = filterBuilderProps[0];
const filter = {
key: selectedFilterBuilderProp.name,
value: getDefaultFilterValue(selectedFilterBuilderProp),
type: getDefaultFilterType(selectedFilterBuilderProp)
};
this.selectedFilterBuilderProp = selectedFilterBuilderProp;
onFilterChange(index, filter);
}
//
// Listeners
onFilterKeyChange = ({ value: key }) => {
const {
index,
filterBuilderProps,
onFilterChange
} = this.props;
const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key);
const type = getDefaultFilterType(selectedFilterBuilderProp);
const filter = {
key,
value: getDefaultFilterValue(selectedFilterBuilderProp),
type
};
this.selectedFilterBuilderProp = selectedFilterBuilderProp;
onFilterChange(index, filter);
};
onFilterChange = ({ name, value }) => {
const {
index,
filterKey,
filterValue,
filterType,
onFilterChange
} = this.props;
const filter = {
key: filterKey,
value: filterValue,
type: filterType
};
filter[name] = value;
onFilterChange(index, filter);
};
onAddPress = () => {
const {
index,
onAddPress
} = this.props;
onAddPress(index);
};
onRemovePress = () => {
const {
index,
onRemovePress
} = this.props;
onRemovePress(index);
};
//
// Render
render() {
const {
filterKey,
filterType,
filterValue,
filterCount,
filterBuilderProps,
sectionItems
} = this.props;
const selectedFilterBuilderProp = this.selectedFilterBuilderProp;
const keyOptions = filterBuilderProps.map((availablePropFilter) => {
const { name, label } = availablePropFilter;
return {
key: name,
value: typeof label === 'function' ? label() : label
};
}).sort(sortByProp('value'));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
return (
<div className={styles.filterRow}>
<div className={styles.inputContainer}>
{
filterKey &&
<SelectInput
name="key"
value={filterKey}
values={keyOptions}
onChange={this.onFilterKeyChange}
/>
}
</div>
<div className={styles.inputContainer}>
{
filterType &&
<SelectInput
name="type"
value={filterType}
values={getFilterTypeOptions(filterBuilderProps, filterKey)}
onChange={this.onFilterChange}
/>
}
</div>
<div className={styles.valueInputContainer}>
{
filterValue != null && !!selectedFilterBuilderProp &&
<ValueComponent
filterType={filterType}
filterValue={filterValue}
selectedFilterBuilderProp={selectedFilterBuilderProp}
sectionItems={sectionItems}
onChange={this.onFilterChange}
/>
}
</div>
<div className={styles.actionsContainer}>
<IconButton
name={icons.SUBTRACT}
isDisabled={filterCount === 1}
onPress={this.onRemovePress}
/>
<IconButton
name={icons.ADD}
onPress={this.onAddPress}
/>
</div>
</div>
);
}
}
FilterBuilderRow.propTypes = {
index: PropTypes.number.isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]),
filterType: PropTypes.string,
filterCount: PropTypes.number.isRequired,
filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
onFilterChange: PropTypes.func.isRequired,
onAddPress: PropTypes.func.isRequired,
onRemovePress: PropTypes.func.isRequired
};
export default FilterBuilderRow;

View file

@ -0,0 +1,314 @@
import React, { useCallback, useEffect, useState } from 'react';
import { FilterBuilderProp, PropertyFilter } from 'App/State/AppState';
import SelectInput from 'Components/Form/SelectInput';
import IconButton from 'Components/Link/IconButton';
import { filterBuilderValueTypes, icons } from 'Helpers/Props';
import {
FilterBuilderTypes,
possibleFilterTypes,
} from 'Helpers/Props/filterBuilderTypes';
import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp';
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
import DateFilterBuilderRowValue, {
DateFilterValue,
} from './DateFilterBuilderRowValue';
import DefaultFilterBuilderRowValue from './DefaultFilterBuilderRowValue';
import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import IndexerFilterBuilderRowValue from './IndexerFilterBuilderRowValue';
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValue from './QualityFilterBuilderRowValue';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
import TagFilterBuilderRowValue from './TagFilterBuilderRowValue';
import styles from './FilterBuilderRow.css';
function getselectedFilterBuilderProp<T>(
filterBuilderProps: FilterBuilderProp<T>[],
name: string
) {
return filterBuilderProps.find((a) => {
return a.name === name;
});
}
function getFilterTypeOptions<T>(
filterBuilderProps: FilterBuilderProp<T>[],
filterKey: string | undefined
) {
if (!filterKey) {
return [];
}
const selectedFilterBuilderProp = getselectedFilterBuilderProp(
filterBuilderProps,
filterKey
);
if (!selectedFilterBuilderProp) {
return [];
}
return possibleFilterTypes[selectedFilterBuilderProp.type];
}
function getDefaultFilterType<T>(
selectedFilterBuilderProp: FilterBuilderProp<T>
) {
return possibleFilterTypes[selectedFilterBuilderProp.type][0].key;
}
function getDefaultFilterValue<T>(
selectedFilterBuilderProp: FilterBuilderProp<T>
) {
if (selectedFilterBuilderProp.type === 'date') {
return '';
}
return [];
}
function getRowValueConnector<T>(
selectedFilterBuilderProp: FilterBuilderProp<T> | undefined
) {
if (!selectedFilterBuilderProp) {
return DefaultFilterBuilderRowValue;
}
const valueType = selectedFilterBuilderProp.valueType;
switch (valueType) {
case filterBuilderValueTypes.BOOL:
return BoolFilterBuilderRowValue;
case filterBuilderValueTypes.DATE:
return DateFilterBuilderRowValue;
case filterBuilderValueTypes.HISTORY_EVENT_TYPE:
return HistoryEventTypeFilterBuilderRowValue;
case filterBuilderValueTypes.INDEXER:
return IndexerFilterBuilderRowValue;
case filterBuilderValueTypes.LANGUAGE:
return LanguageFilterBuilderRowValue;
case filterBuilderValueTypes.PROTOCOL:
return ProtocolFilterBuilderRowValue;
case filterBuilderValueTypes.QUALITY:
return QualityFilterBuilderRowValue;
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue;
case filterBuilderValueTypes.QUEUE_STATUS:
return QueueStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES:
return SeriesFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES_STATUS:
return SeriesStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES_TYPES:
return SeriesTypeFilterBuilderRowValue;
case filterBuilderValueTypes.TAG:
return TagFilterBuilderRowValue;
default:
return DefaultFilterBuilderRowValue;
}
}
interface FilterBuilderRowProps<T> {
index: number;
filterKey?: string;
filterValue?: (DateFilterValue | string) | string[] | number[] | boolean[];
filterType?: string;
filterCount: number;
filterBuilderProps: FilterBuilderProp<T>[];
sectionItems: T[];
onAddPress: () => void;
onFilterChange: (index: number, filter: PropertyFilter) => void;
onRemovePress: (index: number) => void;
}
function FilterBuilderRow<T>({
index,
filterKey,
filterType,
filterValue,
filterCount,
filterBuilderProps,
sectionItems,
onAddPress,
onFilterChange,
onRemovePress,
}: FilterBuilderRowProps<T>) {
const [selectedFilterBuilderProp, setSelectedFilterBuilderProp] = useState<
FilterBuilderProp<T> | undefined
>(undefined);
const keyOptions = filterBuilderProps
.map((availablePropFilter) => {
const { name, label } = availablePropFilter;
return {
key: name,
value: typeof label === 'function' ? label() : label,
};
})
.sort(sortByProp('value'));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
const handleFilterKeyChange = useCallback(
({ value: key }: InputChanged<FilterBuilderTypes>) => {
const selected = getselectedFilterBuilderProp(filterBuilderProps, key);
if (!selected) {
return;
}
const type = getDefaultFilterType(selected);
const filter = {
key,
value: getDefaultFilterValue(selected),
type,
};
setSelectedFilterBuilderProp(selected);
onFilterChange(index, filter);
},
[filterBuilderProps, index, onFilterChange]
);
const handleFilterTypeChange = useCallback(
({ value }: InputChanged<string>) => {
if (filterKey == null || filterValue == null || filterType == null) {
return;
}
const filter: PropertyFilter = {
key: filterKey,
value: filterValue,
type: value,
};
onFilterChange(index, filter);
},
[index, filterKey, filterValue, filterType, onFilterChange]
);
const handleFilterValueChange = useCallback(
({
value,
}: InputChanged<
string | boolean[] | string[] | number[] | DateFilterValue
>) => {
if (filterKey == null || filterValue == null || filterType == null) {
return;
}
const filter: PropertyFilter = {
key: filterKey,
value,
type: filterType,
};
onFilterChange(index, filter);
},
[index, filterKey, filterValue, filterType, onFilterChange]
);
const handleAddPress = useCallback(() => {
onAddPress();
}, [onAddPress]);
const handleRemovePress = useCallback(() => {
onRemovePress(index);
}, [index, onRemovePress]);
useEffect(() => {
if (filterKey) {
setSelectedFilterBuilderProp(
filterBuilderProps.find((a) => a.name === filterKey)
);
return;
}
const selected = filterBuilderProps[0];
const filter = {
key: selected.name,
value: getDefaultFilterValue(selected),
type: getDefaultFilterType(selected),
};
setSelectedFilterBuilderProp(selected);
onFilterChange(index, filter);
}, [index, filterKey, filterBuilderProps, onFilterChange]);
return (
<div className={styles.filterRow}>
<div className={styles.inputContainer}>
{filterKey ? (
<SelectInput
name="key"
value={filterKey}
values={keyOptions}
onChange={handleFilterKeyChange}
/>
) : null}
</div>
<div className={styles.inputContainer}>
{filterType ? (
<SelectInput
name="type"
value={filterType}
values={getFilterTypeOptions(filterBuilderProps, filterKey)}
onChange={handleFilterTypeChange}
/>
) : null}
</div>
<div className={styles.valueInputContainer}>
{filterValue != null && !!selectedFilterBuilderProp ? (
<ValueComponent
filterType={filterType}
// @ts-expect-error - inferring the correct value type is hard
filterValue={filterValue}
selectedFilterBuilderProp={selectedFilterBuilderProp}
sectionItems={sectionItems}
onChange={handleFilterValueChange}
/>
) : null}
</div>
<div className={styles.actionsContainer}>
<IconButton
name={icons.SUBTRACT}
isDisabled={filterCount === 1}
onPress={handleRemovePress}
/>
<IconButton name={icons.ADD} onPress={handleAddPress} />
</div>
</div>
);
}
export default FilterBuilderRow;

View file

@ -1,160 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TagInput from 'Components/Form/Tag/TagInput';
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import convertToBytes from 'Utilities/Number/convertToBytes';
import formatBytes from 'Utilities/Number/formatBytes';
import FilterBuilderRowValueTag from './FilterBuilderRowValueTag';
export const NAME = 'value';
function getTagDisplayValue(value, selectedFilterBuilderProp) {
if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
return formatBytes(value);
}
return value;
}
function getValue(input, selectedFilterBuilderProp) {
if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
const match = input.match(/^(\d+)([kmgt](i?b)?)$/i);
if (match && match.length > 1) {
const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i);
switch (unit.toLowerCase()) {
case 'k':
return convertToBytes(value, 1, true);
case 'm':
return convertToBytes(value, 2, true);
case 'g':
return convertToBytes(value, 3, true);
case 't':
return convertToBytes(value, 4, true);
case 'kb':
return convertToBytes(value, 1, true);
case 'mb':
return convertToBytes(value, 2, true);
case 'gb':
return convertToBytes(value, 3, true);
case 'tb':
return convertToBytes(value, 4, true);
case 'kib':
return convertToBytes(value, 1, true);
case 'mib':
return convertToBytes(value, 2, true);
case 'gib':
return convertToBytes(value, 3, true);
case 'tib':
return convertToBytes(value, 4, true);
default:
return parseInt(value);
}
}
}
if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) {
return parseInt(input);
}
return input;
}
class FilterBuilderRowValue extends Component {
//
// Listeners
onTagAdd = (tag) => {
const {
filterValue,
selectedFilterBuilderProp,
onChange
} = this.props;
let value = tag.id;
if (value == null) {
value = getValue(tag.name, selectedFilterBuilderProp);
}
onChange({
name: NAME,
value: [...filterValue, value]
});
};
onTagDelete = ({ index }) => {
const {
filterValue,
onChange
} = this.props;
const value = filterValue.filter((v, i) => i !== index);
onChange({
name: NAME,
value
});
};
//
// Render
render() {
const {
filterValue,
selectedFilterBuilderProp,
tagList
} = this.props;
const hasItems = !!tagList.length;
const tags = filterValue.map((id) => {
if (hasItems) {
const tag = tagList.find((t) => t.id === id);
return {
id,
name: tag && tag.name
};
}
return {
id,
name: getTagDisplayValue(id, selectedFilterBuilderProp)
};
});
return (
<TagInput
name={NAME}
tags={tags}
tagList={tagList}
allowNew={!tagList.length}
kind={kinds.DEFAULT}
delimiters={['Tab', 'Enter']}
maxSuggestionsLength={100}
minQueryLength={0}
tagComponent={FilterBuilderRowValueTag}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
/>
);
}
}
FilterBuilderRowValue.propTypes = {
filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])).isRequired,
selectedFilterBuilderProp: PropTypes.object.isRequired,
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
onChange: PropTypes.func.isRequired
};
FilterBuilderRowValue.defaultProps = {
filterValue: []
};
export default FilterBuilderRowValue;

View file

@ -0,0 +1,180 @@
import React, { useCallback, useMemo } from 'react';
import { FilterBuilderProp } from 'App/State/AppState';
import TagInput, { TagBase } from 'Components/Form/Tag/TagInput';
import {
filterBuilderTypes,
filterBuilderValueTypes,
kinds,
} from 'Helpers/Props';
import { InputOnChange } from 'typings/inputs';
import convertToBytes from 'Utilities/Number/convertToBytes';
import formatBytes from 'Utilities/Number/formatBytes';
import FilterBuilderRowValueTag from './FilterBuilderRowValueTag';
export const NAME = 'value';
function getTagDisplayValue<T>(
value: string | number | boolean,
selectedFilterBuilderProp: FilterBuilderProp<T>
) {
if (
(typeof value === 'string' || typeof value === 'number') &&
selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES
) {
return formatBytes(value);
}
return String(value);
}
function getValue<T>(
input: string | number | boolean,
selectedFilterBuilderProp: FilterBuilderProp<T>
) {
if (typeof input !== 'string') {
return input;
}
if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
const match = input.match(/^(\d+)([kmgt](i?b)?)$/i);
if (match && match.length > 1) {
const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i) ?? [];
switch (unit.toLowerCase()) {
case 'k':
return convertToBytes(value, 1, true);
case 'm':
return convertToBytes(value, 2, true);
case 'g':
return convertToBytes(value, 3, true);
case 't':
return convertToBytes(value, 4, true);
case 'kb':
return convertToBytes(value, 1, true);
case 'mb':
return convertToBytes(value, 2, true);
case 'gb':
return convertToBytes(value, 3, true);
case 'tb':
return convertToBytes(value, 4, true);
case 'kib':
return convertToBytes(value, 1, true);
case 'mib':
return convertToBytes(value, 2, true);
case 'gib':
return convertToBytes(value, 3, true);
case 'tib':
return convertToBytes(value, 4, true);
default:
return parseInt(value);
}
}
}
if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) {
return parseInt(input as string);
}
return input;
}
interface FreeFormValue {
name: string;
}
export interface FilterBuilderTag<V extends string | number | boolean>
extends TagBase {
id: V;
name: string | number;
}
export interface FilterBuilderRowValueProps<
T,
V extends string | number | boolean
> {
filterType?: string;
filterValue: V[];
sectionItems: T[];
selectedFilterBuilderProp: FilterBuilderProp<T>;
tagList: FilterBuilderTag<V>[];
onChange: InputOnChange<V[]>;
}
function FilterBuilderRowValue<T, V extends string | number | boolean>({
filterValue = [],
selectedFilterBuilderProp,
tagList,
onChange,
}: FilterBuilderRowValueProps<T, V>) {
const hasItems = !!tagList.length;
const tags = useMemo(() => {
return filterValue.map((id) => {
if (hasItems) {
const tag = tagList.find((t) => t.id === id);
return {
id,
name: tag ? tag.name : '',
};
}
return {
id,
name: getTagDisplayValue(id, selectedFilterBuilderProp),
};
});
}, [filterValue, tagList, selectedFilterBuilderProp, hasItems]);
const handleTagAdd = useCallback(
(tag: FilterBuilderTag<V> | FreeFormValue) => {
if ('id' in tag) {
onChange({
name: NAME,
value: [...filterValue, tag.id],
});
return;
}
// Cast to V to avoid TS error combining V and value of V
const value = getValue(tag.name, selectedFilterBuilderProp) as V;
onChange({
name: NAME,
value: [...filterValue, value],
});
},
[filterValue, selectedFilterBuilderProp, onChange]
);
const handleTagDelete = useCallback(
({ index }: { index: number }) => {
const value = filterValue.filter((_v, i) => i !== index);
onChange({
name: NAME,
value,
});
},
[filterValue, onChange]
);
return (
<TagInput
name={NAME}
tags={tags}
tagList={tagList}
allowNew={!tagList.length}
kind={kinds.DEFAULT}
delimiters={['Tab', 'Enter']}
minQueryLength={0}
tagComponent={FilterBuilderRowValueTag}
onTagAdd={handleTagAdd}
onTagDelete={handleTagDelete}
/>
);
}
export default FilterBuilderRowValue;

View file

@ -1,60 +0,0 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { filterBuilderTypes } from 'Helpers/Props';
import * as filterTypes from 'Helpers/Props/filterTypes';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createTagListSelector() {
return createSelector(
(state, { filterType }) => filterType,
(state, { sectionItems }) => sectionItems,
(state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp,
(filterType, sectionItems, selectedFilterBuilderProp) => {
if (
(selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ||
selectedFilterBuilderProp.type === filterBuilderTypes.STRING) &&
filterType !== filterTypes.EQUAL &&
filterType !== filterTypes.NOT_EQUAL ||
!selectedFilterBuilderProp.optionsSelector
) {
return [];
}
let items = [];
if (selectedFilterBuilderProp.optionsSelector) {
items = selectedFilterBuilderProp.optionsSelector(sectionItems);
} else {
items = sectionItems.reduce((acc, item) => {
const name = item[selectedFilterBuilderProp.name];
if (name) {
acc.push({
id: name,
name
});
}
return acc;
}, []).sort(sortByProp('name'));
}
return _.uniqBy(items, 'id');
}
);
}
function createMapStateToProps() {
return createSelector(
createTagListSelector(),
(tagList) => {
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View file

@ -1,16 +0,0 @@
import { FilterBuilderProp } from 'App/State/AppState';
interface FilterBuilderRowOnChangeProps {
name: string;
value: unknown[];
}
interface FilterBuilderRowValueProps {
filterType?: string;
filterValue: string | number | object | string[] | number[] | object[];
selectedFilterBuilderProp: FilterBuilderProp<unknown>;
sectionItem: unknown[];
onChange: (payload: FilterBuilderRowOnChangeProps) => void;
}
export default FilterBuilderRowValueProps;

View file

@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import TagInputTag from 'Components/Form/Tag/TagInputTag';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './FilterBuilderRowValueTag.css';
function FilterBuilderRowValueTag(props) {
return (
<div
className={styles.tag}
>
<TagInputTag
kind={kinds.DEFAULT}
{...props}
/>
{
props.isLastTag ?
null :
<div className={styles.or}>
{translate('Or')}
</div>
}
</div>
);
}
FilterBuilderRowValueTag.propTypes = {
isLastTag: PropTypes.bool.isRequired
};
export default FilterBuilderRowValueTag;

View file

@ -0,0 +1,25 @@
import React from 'react';
import { TagBase } from 'Components/Form/Tag/TagInput';
import TagInputTag, { TagInputTagProps } from 'Components/Form/Tag/TagInputTag';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './FilterBuilderRowValueTag.css';
interface FilterBuilderRowValueTagProps extends TagInputTagProps<TagBase> {
isLastTag: boolean;
}
function FilterBuilderRowValueTag({
isLastTag,
...otherProps
}: FilterBuilderRowValueTagProps) {
return (
<div className={styles.tag}>
<TagInputTag {...otherProps} kind={kinds.DEFAULT} />
{isLastTag ? null : <div className={styles.or}>{translate('Or')}</div>}
</div>
);
}
export default FilterBuilderRowValueTag;

View file

@ -1,7 +1,8 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
const EVENT_TYPE_OPTIONS = [
{
@ -42,8 +43,13 @@ const EVENT_TYPE_OPTIONS = [
},
];
function HistoryEventTypeFilterBuilderRowValue(
props: FilterBuilderRowValueProps
type QualityProfileFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
'tagList'
>;
function HistoryEventTypeFilterBuilderRowValue<T>(
props: QualityProfileFilterBuilderRowValueProps<T>
) {
return <FilterBuilderRowValue {...props} tagList={EVENT_TYPE_OPTIONS} />;
}

View file

@ -0,0 +1,41 @@
import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { fetchIndexers } from 'Store/Actions/settingsActions';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
type IndexerFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
'tagList'
>;
function IndexerFilterBuilderRowValue<T>(
props: IndexerFilterBuilderRowValueProps<T>
) {
const dispatch = useDispatch();
const { isPopulated, items } = useSelector(
(state: AppState) => state.settings.indexers
);
const tagList = useMemo(() => {
return items.map((item) => {
return {
id: item.id,
name: item.name,
};
});
}, [items]);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchIndexers());
}
}, [isPopulated, dispatch]);
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default IndexerFilterBuilderRowValue;

View file

@ -1,79 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import { fetchIndexers } from 'Store/Actions/settingsActions';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.indexers,
(qualityProfiles) => {
const {
isFetching,
isPopulated,
error,
items
} = qualityProfiles;
const tagList = items.map((item) => {
return {
id: item.id,
name: item.name
};
});
return {
isFetching,
isPopulated,
error,
tagList
};
}
);
}
const mapDispatchToProps = {
dispatchFetchIndexers: fetchIndexers
};
class IndexerFilterBuilderRowValueConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchIndexers();
}
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
...otherProps
} = this.props;
return (
<FilterBuilderRowValue
{...otherProps}
/>
);
}
}
IndexerFilterBuilderRowValueConnector.propTypes = {
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
dispatchFetchIndexers: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector);

View file

@ -1,10 +1,18 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
function LanguageFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
type LanguageFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
'tagList'
>;
function LanguageFilterBuilderRowValue<T>(
props: LanguageFilterBuilderRowValueProps<T>
) {
const { items } = useSelector(createLanguagesSelector());
return <FilterBuilderRowValue {...props} tagList={items} />;

View file

@ -1,18 +0,0 @@
import React from 'react';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const protocols = [
{ id: 'torrent', name: 'Torrent' },
{ id: 'usenet', name: 'Usenet' }
];
function ProtocolFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={protocols}
{...props}
/>
);
}
export default ProtocolFilterBuilderRowValue;

View file

@ -0,0 +1,22 @@
import React from 'react';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
const protocols = [
{ id: 'torrent', name: 'Torrent' },
{ id: 'usenet', name: 'Usenet' },
];
type ProtocolFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
'tagList'
>;
function ProtocolFilterBuilderRowValue<T>(
props: ProtocolFilterBuilderRowValueProps<T>
) {
return <FilterBuilderRowValue tagList={protocols} {...props} />;
}
export default ProtocolFilterBuilderRowValue;

View file

@ -0,0 +1,37 @@
import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import getQualities from 'Utilities/Quality/getQualities';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
type QualityFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
'tagList'
>;
function QualityFilterBuilderRowValue<T>(
props: QualityFilterBuilderRowValueProps<T>
) {
const dispatch = useDispatch();
const { isSchemaPopulated, schema } = useSelector(
(state: AppState) => state.settings.qualityProfiles
);
const tagList = useMemo(() => {
return getQualities(schema.items);
}, [schema]);
useEffect(() => {
if (!isSchemaPopulated) {
dispatch(fetchQualityProfileSchema());
}
}, [isSchemaPopulated, dispatch]);
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default QualityFilterBuilderRowValue;

View file

@ -1,75 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import getQualities from 'Utilities/Quality/getQualities';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema
} = qualityProfiles;
const tagList = getQualities(schema.items);
return {
isFetching,
isPopulated,
error,
tagList
};
}
);
}
const mapDispatchToProps = {
dispatchFetchQualityProfileSchema: fetchQualityProfileSchema
};
class QualityFilterBuilderRowValueConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchQualityProfileSchema();
}
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
...otherProps
} = this.props;
return (
<FilterBuilderRowValue
{...otherProps}
/>
);
}
}
QualityFilterBuilderRowValueConnector.propTypes = {
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
dispatchFetchQualityProfileSchema: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector);

View file

@ -2,9 +2,10 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
function createQualityProfilesSelector() {
return createSelector(
@ -15,8 +16,13 @@ function createQualityProfilesSelector() {
);
}
function QualityProfileFilterBuilderRowValue(
props: FilterBuilderRowValueProps
type QualityProfileFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
'tagList'
>;
function QualityProfileFilterBuilderRowValue<T>(
props: QualityProfileFilterBuilderRowValueProps<T>
) {
const qualityProfiles = useSelector(createQualityProfilesSelector());

View file

@ -1,7 +1,8 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
const statusTagList = [
{
@ -60,7 +61,14 @@ const statusTagList = [
},
];
function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
type QueueStatusFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
'tagList'
>;
function QueueStatusFilterBuilderRowValue<T>(
props: QueueStatusFilterBuilderRowValueProps<T>
) {
return <FilterBuilderRowValue {...props} tagList={statusTagList} />;
}

View file

@ -1,34 +1,40 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
const seasonsMonitoredStatusList = [
{
id: 'all',
get name() {
return translate('SeasonsMonitoredAll');
}
},
},
{
id: 'partial',
get name() {
return translate('SeasonsMonitoredPartial');
}
},
},
{
id: 'none',
get name() {
return translate('SeasonsMonitoredNone');
}
}
},
},
];
function SeasonsMonitoredStatusFilterBuilderRowValue(props) {
type SeasonsMonitoredStatusFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
'tagList'
>;
function SeasonsMonitoredStatusFilterBuilderRowValue<T>(
props: SeasonsMonitoredStatusFilterBuilderRowValueProps<T>
) {
return (
<FilterBuilderRowValue
tagList={seasonsMonitoredStatusList}
{...props}
/>
<FilterBuilderRowValue tagList={seasonsMonitoredStatusList} {...props} />
);
}

View file

@ -3,10 +3,18 @@ import { useSelector } from 'react-redux';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
function SeriesFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
type SeriesFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
'tagList'
>;
function SeriesFilterBuilderRowValue<T>(
props: SeriesFilterBuilderRowValueProps<T>
) {
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const tagList = allSeries

View file

@ -1,41 +1,45 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
const statusTagList = [
{
id: 'continuing',
get name() {
return translate('Continuing');
}
},
},
{
id: 'upcoming',
get name() {
return translate('Upcoming');
}
},
},
{
id: 'ended',
get name() {
return translate('Ended');
}
},
},
{
id: 'deleted',
get name() {
return translate('Deleted');
}
}
},
},
];
function SeriesStatusFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={statusTagList}
{...props}
/>
);
type SeriesStatusFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
'tagList'
>;
function SeriesStatusFilterBuilderRowValue<T>(
props: SeriesStatusFilterBuilderRowValueProps<T>
) {
return <FilterBuilderRowValue tagList={statusTagList} {...props} />;
}
export default SeriesStatusFilterBuilderRowValue;

View file

@ -1,35 +0,0 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const seriesTypeList = [
{
id: 'anime',
get name() {
return translate('Anime');
}
},
{
id: 'daily',
get name() {
return translate('Daily');
}
},
{
id: 'standard',
get name() {
return translate('Standard');
}
}
];
function SeriesTypeFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={seriesTypeList}
{...props}
/>
);
}
export default SeriesTypeFilterBuilderRowValue;

View file

@ -0,0 +1,39 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
const seriesTypeList = [
{
id: 'anime',
get name() {
return translate('Anime');
},
},
{
id: 'daily',
get name() {
return translate('Daily');
},
},
{
id: 'standard',
get name() {
return translate('Standard');
},
},
];
type SeriesTypeFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
'tagList'
>;
function SeriesTypeFilterBuilderRowValue<T>(
props: SeriesTypeFilterBuilderRowValueProps<T>
) {
return <FilterBuilderRowValue tagList={seriesTypeList} {...props} />;
}
export default SeriesTypeFilterBuilderRowValue;

View file

@ -0,0 +1,30 @@
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
type TagFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
'tagList'
>;
function TagFilterBuilderRowValue<T>(props: TagFilterBuilderRowValueProps<T>) {
const tags = useSelector(createTagsSelector());
const tagList = useMemo(() => {
return tags.map((tag) => {
const { id, label } = tag;
return {
id,
name: label,
};
});
}, [tags]);
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default TagFilterBuilderRowValue;

View file

@ -1,27 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
createTagsSelector(),
(tagList) => {
return {
tagList: tagList.map((tag) => {
const {
id,
label: name
} = tag;
return {
id,
name
};
})
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View file

@ -1,115 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './CustomFilter.css';
class CustomFilter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDeleting: false
};
}
componentDidUpdate(prevProps) {
const {
isDeleting,
deleteError
} = this.props;
if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) {
this.setState({ isDeleting: false });
}
}
componentWillUnmount() {
const {
id,
selectedFilterKey,
dispatchSetFilter
} = this.props;
// Assume that delete and then unmounting means the deletion was successful.
// Moving this check to an ancestor would be more accurate, but would have
// more boilerplate.
if (this.state.isDeleting && id === selectedFilterKey) {
dispatchSetFilter({ selectedFilterKey: 'all' });
}
}
//
// Listeners
onEditPress = () => {
const {
id,
onEditPress
} = this.props;
onEditPress(id);
};
onRemovePress = () => {
const {
id,
dispatchDeleteCustomFilter
} = this.props;
this.setState({ isDeleting: true }, () => {
dispatchDeleteCustomFilter({ id });
});
};
//
// Render
render() {
const {
label
} = this.props;
return (
<div className={styles.customFilter}>
<div className={styles.label}>
{label}
</div>
<div className={styles.actions}>
<IconButton
name={icons.EDIT}
onPress={this.onEditPress}
/>
<SpinnerIconButton
title={translate('RemoveFilter')}
name={icons.REMOVE}
isSpinning={this.state.isDeleting}
onPress={this.onRemovePress}
/>
</div>
</div>
);
}
}
CustomFilter.propTypes = {
id: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
dispatchSetFilter: PropTypes.func.isRequired,
onEditPress: PropTypes.func.isRequired,
dispatchDeleteCustomFilter: PropTypes.func.isRequired
};
export default CustomFilter;

View file

@ -0,0 +1,78 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Error } from 'App/State/AppSectionState';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import { deleteCustomFilter } from 'Store/Actions/customFilterActions';
import translate from 'Utilities/String/translate';
import styles from './CustomFilter.css';
interface CustomFilterProps {
id: number;
label: string;
isDeleting: boolean;
deleteError?: Error;
dispatchSetFilter: (payload: { selectedFilterKey: string | number }) => void;
onEditPress: (id: number) => void;
}
function CustomFilter({
id,
label,
isDeleting,
deleteError,
dispatchSetFilter,
onEditPress,
}: CustomFilterProps) {
const dispatch = useDispatch();
const wasDeleting = usePrevious(isDeleting);
const [isDeletingInternal, setIsDeletingInternal] = useState(false);
const handleEditPress = useCallback(() => {
onEditPress(id);
}, [id, onEditPress]);
const handleRemovePress = useCallback(() => {
setIsDeletingInternal(true);
dispatch(deleteCustomFilter({ id }));
}, [id, dispatch]);
useEffect(() => {
if (wasDeleting && !isDeleting && isDeletingInternal && deleteError) {
setIsDeletingInternal(false);
}
}, [isDeleting, isDeletingInternal, wasDeleting, deleteError]);
useEffect(() => {
return () => {
// Assume that delete and then unmounting means the deletion was successful.
// Moving this check to an ancestor would be more accurate, but would have
// more boilerplate.
if (isDeletingInternal) {
dispatchSetFilter({ selectedFilterKey: 'all' });
}
};
}, [isDeletingInternal, dispatchSetFilter, dispatch]);
return (
<div className={styles.customFilter}>
<div className={styles.label}>{label}</div>
<div className={styles.actions}>
<IconButton name={icons.EDIT} onPress={handleEditPress} />
<SpinnerIconButton
title={translate('RemoveFilter')}
name={icons.REMOVE}
isSpinning={isDeleting}
onPress={handleRemovePress}
/>
</div>
</div>
);
}
export default CustomFilter;

View file

@ -1,84 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import CustomFilter from './CustomFilter';
import styles from './CustomFiltersModalContent.css';
function CustomFiltersModalContent(props) {
const {
selectedFilterKey,
customFilters,
isDeleting,
deleteError,
dispatchDeleteCustomFilter,
dispatchSetFilter,
onAddCustomFilter,
onEditCustomFilter,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CustomFilters')}
</ModalHeader>
<ModalBody>
{
customFilters
.sort((a, b) => sortByProp(a, b, 'label'))
.map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
})
}
<div className={styles.addButtonContainer}>
<Button onPress={onAddCustomFilter}>
{translate('AddCustomFilter')}
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
CustomFiltersModalContent.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
dispatchDeleteCustomFilter: PropTypes.func.isRequired,
dispatchSetFilter: PropTypes.func.isRequired,
onAddCustomFilter: PropTypes.func.isRequired,
onEditCustomFilter: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CustomFiltersModalContent;

View file

@ -0,0 +1,68 @@
import React from 'react';
import { useSelector } from 'react-redux';
import AppState, {
CustomFilter as CustomFilterModel,
} from 'App/State/AppState';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import CustomFilter from './CustomFilter';
import styles from './CustomFiltersModalContent.css';
interface CustomFiltersModalContentProps {
customFilters: CustomFilterModel[];
dispatchSetFilter: (payload: { selectedFilterKey: string | number }) => void;
onAddCustomFilter: () => void;
onEditCustomFilter: (id: number) => void;
onModalClose: () => void;
}
function CustomFiltersModalContent({
customFilters,
dispatchSetFilter,
onAddCustomFilter,
onEditCustomFilter,
onModalClose,
}: CustomFiltersModalContentProps) {
const { isDeleting, deleteError } = useSelector(
(state: AppState) => state.customFilters
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('CustomFilters')}</ModalHeader>
<ModalBody>
{customFilters.sort(sortByProp('label')).map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
onEditPress={onEditCustomFilter}
/>
);
})}
<div className={styles.addButtonContainer}>
<Button onPress={onAddCustomFilter}>
{translate('AddCustomFilter')}
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default CustomFiltersModalContent;

View file

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteCustomFilter } from 'Store/Actions/customFilterActions';
import CustomFiltersModalContent from './CustomFiltersModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.customFilters.isDeleting,
(state) => state.customFilters.deleteError,
(isDeleting, deleteError) => {
return {
isDeleting,
deleteError
};
}
);
}
const mapDispatchToProps = {
dispatchDeleteCustomFilter: deleteCustomFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent);

View file

@ -1,102 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector';
import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector';
class FilterModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
filterBuilder: !props.customFilters.length,
id: null
};
}
//
// Listeners
onAddCustomFilter = () => {
this.setState({
filterBuilder: true
});
};
onEditCustomFilter = (id) => {
this.setState({
filterBuilder: true,
id
});
};
onCancelPress = () => {
if (this.state.filterBuilder) {
this.setState({
filterBuilder: false,
id: null
});
} else {
this.onModalClose();
}
};
onModalClose = () => {
this.setState({
filterBuilder: false,
id: null
}, () => {
this.props.onModalClose();
});
};
//
// Render
render() {
const {
isOpen,
...otherProps
} = this.props;
const {
filterBuilder,
id
} = this.state;
return (
<Modal
isOpen={isOpen}
onModalClose={this.onModalClose}
>
{
filterBuilder ?
<FilterBuilderModalContentConnector
{...otherProps}
id={id}
onCancelPress={this.onCancelPress}
onModalClose={this.onModalClose}
/> :
<CustomFiltersModalContentConnector
{...otherProps}
onAddCustomFilter={this.onAddCustomFilter}
onEditCustomFilter={this.onEditCustomFilter}
onModalClose={this.onModalClose}
/>
}
</Modal>
);
}
}
FilterModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired
};
export default FilterModal;

View file

@ -0,0 +1,73 @@
import React, { useCallback, useState } from 'react';
import { CustomFilter, FilterBuilderProp } from 'App/State/AppState';
import Modal from 'Components/Modal/Modal';
import FilterBuilderModalContent from './Builder/FilterBuilderModalContent';
import CustomFiltersModalContent from './CustomFilters/CustomFiltersModalContent';
export interface FilterModalProps<T> {
isOpen: boolean;
customFilters: CustomFilter[];
customFilterType: string;
filterBuilderProps: FilterBuilderProp<T>[];
sectionItems: T[];
dispatchSetFilter: (payload: { selectedFilterKey: string | number }) => void;
onModalClose: () => void;
}
function FilterModal<T>({
isOpen,
customFilters,
onModalClose,
...otherProps
}: FilterModalProps<T>) {
const [id, setId] = useState<null | number>(null);
const [filterBuilder, setFilterBuilder] = useState(!customFilters.length);
const handleAddCustomFilter = useCallback(() => {
setFilterBuilder(true);
}, []);
const handleEditCustomFilter = useCallback((id: number) => {
setId(id);
setFilterBuilder(true);
}, []);
const handleCancelPress = useCallback(() => {
if (filterBuilder) {
setId(null);
setFilterBuilder(false);
} else {
onModalClose();
}
}, [filterBuilder, onModalClose]);
const handleModalClose = useCallback(() => {
setId(null);
setFilterBuilder(false);
onModalClose();
}, [onModalClose]);
return (
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
{filterBuilder ? (
<FilterBuilderModalContent
{...otherProps}
id={id}
customFilters={customFilters}
onCancelPress={handleCancelPress}
onModalClose={handleModalClose}
/>
) : (
<CustomFiltersModalContent
{...otherProps}
customFilters={customFilters}
onAddCustomFilter={handleAddCustomFilter}
onEditCustomFilter={handleEditCustomFilter}
onModalClose={handleModalClose}
/>
)}
</Modal>
);
}
export default FilterModal;

View file

@ -119,7 +119,7 @@ function getComponent(type: InputType) {
}
// TODO: Remove once all parent components are updated to TSX and we can refactor to a consistent type
interface ValidationMessage {
export interface ValidationMessage {
message: string;
}

View file

@ -25,11 +25,12 @@ function parseValue(
}
interface NumberInputProps
extends Omit<TextInputProps<number | null>, 'value'> {
extends Omit<TextInputProps<number | null>, 'value' | 'onChange'> {
value?: number | null;
min?: number;
max?: number;
isFloat?: boolean;
onChange: (input: InputChanged<number | null>) => void;
}
function NumberInput({

View file

@ -75,7 +75,7 @@ class FilterMenu extends Component {
</Menu>
{
showCustomFilters &&
showCustomFilters ?
<FilterModalConnectorComponent
{...filterModalConnectorComponentProps}
isOpen={this.state.isFilterModalOpen}
@ -84,7 +84,7 @@ class FilterMenu extends Component {
customFilters={customFilters}
onFilterSelect={onFilterSelect}
onModalClose={this.onFiltersModalClose}
/>
/> : null
}
</div>
);

View file

@ -1,147 +0,0 @@
import translate from 'Utilities/String/translate';
import * as filterTypes from './filterTypes';
export const ARRAY = 'array';
export const CONTAINS = 'contains';
export const DATE = 'date';
export const EQUAL = 'equal';
export const EXACT = 'exact';
export const NUMBER = 'number';
export const STRING = 'string';
export const all = [
ARRAY,
CONTAINS,
DATE,
EQUAL,
EXACT,
NUMBER,
STRING
];
export const possibleFilterTypes = {
[ARRAY]: [
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain')
}
],
[CONTAINS]: [
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
}
],
[DATE]: [
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterIsBefore')
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterIsAfter')
},
{
key: filterTypes.IN_LAST,
value: () => translate('FilterInLast')
},
{
key: filterTypes.NOT_IN_LAST,
value: () => translate('FilterNotInLast')
},
{
key: filterTypes.IN_NEXT,
value: () => translate('FilterInNext')
},
{
key: filterTypes.NOT_IN_NEXT,
value: () => translate('FilterNotInNext')
}
],
[EQUAL]: [
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs')
}
],
[EXACT]: [
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterIsNot')
}
],
[NUMBER]: [
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual')
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterGreaterThan')
},
{
key: filterTypes.GREATER_THAN_OR_EQUAL,
value: () => translate('FilterGreaterThanOrEqual')
},
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterLessThan')
},
{
key: filterTypes.LESS_THAN_OR_EQUAL,
value: () => translate('FilterLessThanOrEqual')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual')
}
],
[STRING]: [
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain')
},
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual')
},
{
key: filterTypes.STARTS_WITH,
value: () => translate('FilterStartsWith')
},
{
key: filterTypes.NOT_STARTS_WITH,
value: () => translate('FilterDoesNotStartWith')
},
{
key: filterTypes.ENDS_WITH,
value: () => translate('FilterEndsWith')
},
{
key: filterTypes.NOT_ENDS_WITH,
value: () => translate('FilterDoesNotEndWith')
}
]
};

View file

@ -0,0 +1,148 @@
import translate from 'Utilities/String/translate';
import * as filterTypes from './filterTypes';
export const ARRAY = 'array';
export const CONTAINS = 'contains';
export const DATE = 'date';
export const EQUAL = 'equal';
export const EXACT = 'exact';
export const NUMBER = 'number';
export const STRING = 'string';
export const all = [ARRAY, CONTAINS, DATE, EQUAL, EXACT, NUMBER, STRING];
export type FilterBuilderTypes =
| 'array'
| 'contains'
| 'date'
| 'equal'
| 'exact'
| 'number'
| 'string';
export const possibleFilterTypes = {
array: [
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains'),
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain'),
},
],
contains: [
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains'),
},
],
date: [
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterIsBefore'),
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterIsAfter'),
},
{
key: filterTypes.IN_LAST,
value: () => translate('FilterInLast'),
},
{
key: filterTypes.NOT_IN_LAST,
value: () => translate('FilterNotInLast'),
},
{
key: filterTypes.IN_NEXT,
value: () => translate('FilterInNext'),
},
{
key: filterTypes.NOT_IN_NEXT,
value: () => translate('FilterNotInNext'),
},
],
equal: [
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs'),
},
],
exact: [
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs'),
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterIsNot'),
},
],
number: [
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual'),
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterGreaterThan'),
},
{
key: filterTypes.GREATER_THAN_OR_EQUAL,
value: () => translate('FilterGreaterThanOrEqual'),
},
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterLessThan'),
},
{
key: filterTypes.LESS_THAN_OR_EQUAL,
value: () => translate('FilterLessThanOrEqual'),
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual'),
},
],
string: [
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains'),
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain'),
},
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual'),
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual'),
},
{
key: filterTypes.STARTS_WITH,
value: () => translate('FilterStartsWith'),
},
{
key: filterTypes.NOT_STARTS_WITH,
value: () => translate('FilterDoesNotStartWith'),
},
{
key: filterTypes.ENDS_WITH,
value: () => translate('FilterEndsWith'),
},
{
key: filterTypes.NOT_ENDS_WITH,
value: () => translate('FilterDoesNotEndWith'),
},
],
};

View file

@ -61,6 +61,7 @@ export type InputType =
| 'autoComplete'
| 'captcha'
| 'check'
| 'date'
| 'device'
| 'keyValueList'
| 'monitorEpisodesSelect'

View file

@ -2,12 +2,13 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import InteractiveSearchType from 'InteractiveSearch/InteractiveSearchType';
import {
setEpisodeReleasesFilter,
setSeasonReleasesFilter,
} from 'Store/Actions/releaseActions';
import Release from 'typings/Release';
function createReleasesSelector() {
return createSelector(
@ -27,8 +28,7 @@ function createFilterBuilderPropsSelector() {
);
}
interface InteractiveSearchFilterModalProps {
isOpen: boolean;
interface InteractiveSearchFilterModalProps extends FilterModalProps<Release> {
type: InteractiveSearchType;
}
@ -38,7 +38,6 @@ export default function InteractiveSearchFilterModal({
}: InteractiveSearchFilterModalProps) {
const sectionItems = useSelector(createReleasesSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'releases';
const dispatch = useDispatch();
@ -54,11 +53,10 @@ export default function InteractiveSearchFilterModal({
return (
<FilterModal
// TODO: Don't spread all the props
{...otherProps}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
customFilterType="releases"
dispatchSetFilter={dispatchSetFilter}
/>
);

View file

@ -2,7 +2,8 @@ import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import Series from 'Series/Series';
import { setSeriesFilter } from 'Store/Actions/seriesIndexActions';
function createSeriesSelector() {
@ -23,16 +24,13 @@ function createFilterBuilderPropsSelector() {
);
}
interface SeriesIndexFilterModalProps {
isOpen: boolean;
}
type SeriesIndexFilterModalProps = FilterModalProps<Series>;
export default function SeriesIndexFilterModal(
props: SeriesIndexFilterModalProps
) {
const sectionItems = useSelector(createSeriesSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'series';
const dispatch = useDispatch();
@ -45,11 +43,10 @@ export default function SeriesIndexFilterModal(
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
customFilterType="series"
dispatchSetFilter={dispatchSetFilter}
/>
);

View file

@ -1,4 +1,8 @@
function convertToBytes(input: number, power: number, binaryPrefix: boolean) {
function convertToBytes(
input: number | string,
power: number,
binaryPrefix: boolean
) {
const size = Number(input);
if (isNaN(size)) {