Convert Add New Series to TypeScript

pull/7605/head
Mark McDowall 1 month ago
parent 9c196c5fa0
commit 37cc66ce66
No known key found for this signature in database

@ -1,215 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons, kinds } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
import styles from './AddNewSeries.css';
class AddNewSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
term: props.term || '',
isFetching: false
};
}
componentDidMount() {
const term = this.state.term;
if (term) {
this.props.onSeriesLookupChange(term);
}
}
componentDidUpdate(prevProps) {
const {
term,
isFetching
} = this.props;
if (term && term !== prevProps.term) {
this.setState({
term,
isFetching: true
});
this.props.onSeriesLookupChange(term);
} else if (isFetching !== prevProps.isFetching) {
this.setState({
isFetching
});
}
}
//
// Listeners
onSearchInputChange = ({ value }) => {
const hasValue = !!value.trim();
this.setState({ term: value, isFetching: hasValue }, () => {
if (hasValue) {
this.props.onSeriesLookupChange(value);
} else {
this.props.onClearSeriesLookup();
}
});
};
onClearSeriesLookupPress = () => {
this.setState({ term: '' });
this.props.onClearSeriesLookup();
};
//
// Render
render() {
const {
error,
items,
hasExistingSeries
} = this.props;
const term = this.state.term;
const isFetching = this.state.isFetching;
return (
<PageContent title={translate('AddNewSeries')}>
<PageContentBody>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon
name={icons.SEARCH}
size={20}
/>
</div>
<TextInput
className={styles.searchInput}
name="seriesLookup"
value={term}
placeholder="eg. Breaking Bad, tvdb:####"
autoFocus={true}
onChange={this.onSearchInputChange}
/>
<Button
className={styles.clearLookupButton}
onPress={this.onClearSeriesLookupPress}
>
<Icon
name={icons.REMOVE}
size={20}
/>
</Button>
</div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error ?
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesError')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
</div> : null
}
{
!isFetching && !error && !!items.length &&
<div className={styles.searchResults}>
{
items.map((item) => {
return (
<AddNewSeriesSearchResultConnector
key={item.tvdbId}
{...item}
/>
);
})
}
</div>
}
{
!isFetching && !error && !items.length && !!term &&
<div className={styles.message}>
<div className={styles.noResults}>{translate('CouldNotFindResults', { term })}</div>
<div>{translate('SearchByTvdbId')}</div>
<div>
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
{translate('WhyCantIFindMyShow')}
</Link>
</div>
</div>
}
{
term ?
null :
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesHelpText')}
</div>
<div>{translate('SearchByTvdbId')}</div>
</div>
}
{
!term && !hasExistingSeries ?
<div className={styles.message}>
<div className={styles.noSeriesText}>
{translate('NoSeriesHaveBeenAdded')}
</div>
<div>
<Button
to="/add/import"
kind={kinds.PRIMARY}
>
{translate('ImportExistingSeries')}
</Button>
</div>
</div> :
null
}
<div />
</PageContentBody>
</PageContent>
);
}
}
AddNewSeries.propTypes = {
term: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
hasExistingSeries: PropTypes.bool.isRequired,
onSeriesLookupChange: PropTypes.func.isRequired,
onClearSeriesLookup: PropTypes.func.isRequired
};
export default AddNewSeries;

@ -0,0 +1,153 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
import styles from './AddNewSeries.css';
function AddNewSeries() {
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
const seriesCount = useSelector(
(state: AppState) => state.series.items.length
);
const [term, setTerm] = useState(initialTerm);
const [isFetching, setIsFetching] = useState(false);
const query = useDebounce(term, term ? 300 : 0);
const handleSearchInputChange = useCallback(
({ value }: InputChanged<string>) => {
setTerm(value);
setIsFetching(!!value.trim());
},
[]
);
const handleClearSeriesLookupPress = useCallback(() => {
setTerm('');
setIsFetching(false);
}, []);
const {
isFetching: isFetchingApi,
error,
data = [],
} = useApiQuery<AddSeries[]>({
path: `/series/lookup?term=${query}`,
queryOptions: {
enabled: !!query,
},
});
useEffect(() => {
setIsFetching(isFetchingApi);
}, [isFetchingApi]);
useEffect(() => {
setTerm(initialTerm);
}, [initialTerm]);
return (
<PageContent title={translate('AddNewSeries')}>
<PageContentBody>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} size={20} />
</div>
<TextInput
className={styles.searchInput}
name="seriesLookup"
value={term}
placeholder="eg. Breaking Bad, tvdb:####"
autoFocus={true}
onChange={handleSearchInputChange}
/>
<Button
className={styles.clearLookupButton}
onPress={handleClearSeriesLookupPress}
>
<Icon name={icons.REMOVE} size={20} />
</Button>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesError')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
</div>
) : null}
{!isFetching && !error && !!data.length ? (
<div className={styles.searchResults}>
{data.map((item) => {
return <AddNewSeriesSearchResult key={item.tvdbId} {...item} />;
})}
</div>
) : null}
{!isFetching && !error && !data.length && term ? (
<div className={styles.message}>
<div className={styles.noResults}>
{translate('CouldNotFindResults', { term })}
</div>
<div>{translate('SearchByTvdbId')}</div>
<div>
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
{translate('WhyCantIFindMyShow')}
</Link>
</div>
</div>
) : null}
{term ? null : (
<div className={styles.message}>
<div className={styles.helpText}>
{translate('AddNewSeriesHelpText')}
</div>
<div>{translate('SearchByTvdbId')}</div>
</div>
)}
{!term && !seriesCount ? (
<div className={styles.message}>
<div className={styles.noSeriesText}>
{translate('NoSeriesHaveBeenAdded')}
</div>
<div>
<Button to="/add/import" kind={kinds.PRIMARY}>
{translate('ImportExistingSeries')}
</Button>
</div>
</div>
) : null}
<div />
</PageContentBody>
</PageContent>
);
}
export default AddNewSeries;

@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearAddSeries, lookupSeries } from 'Store/Actions/addSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import parseUrl from 'Utilities/String/parseUrl';
import AddNewSeries from './AddNewSeries';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.series.items.length,
(state) => state.router.location,
(addSeries, existingSeriesCount, location) => {
const { params } = parseUrl(location.search);
return {
...addSeries,
term: params.term,
hasExistingSeries: existingSeriesCount > 0
};
}
);
}
const mapDispatchToProps = {
lookupSeries,
clearAddSeries,
fetchRootFolders
};
class AddNewSeriesConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._seriesLookupTimeout = null;
}
componentDidMount() {
this.props.fetchRootFolders();
}
componentWillUnmount() {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
this.props.clearAddSeries();
}
//
// Listeners
onSeriesLookupChange = (term) => {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
if (term.trim() === '') {
this.props.clearAddSeries();
} else {
this._seriesLookupTimeout = setTimeout(() => {
this.props.lookupSeries({ term });
}, 300);
}
};
onClearSeriesLookup = () => {
this.props.clearAddSeries();
};
//
// Render
render() {
const {
term,
...otherProps
} = this.props;
return (
<AddNewSeries
term={term}
{...otherProps}
onSeriesLookupChange={this.onSeriesLookupChange}
onClearSeriesLookup={this.onClearSeriesLookup}
/>
);
}
}
AddNewSeriesConnector.propTypes = {
term: PropTypes.string,
lookupSeries: PropTypes.func.isRequired,
clearAddSeries: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);

@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector';
function AddNewSeriesModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddNewSeriesModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddNewSeriesModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddNewSeriesModal;

@ -0,0 +1,23 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddNewSeriesModalContent, {
AddNewSeriesModalContentProps,
} from './AddNewSeriesModalContent';
interface AddNewSeriesModalProps extends AddNewSeriesModalContentProps {
isOpen: boolean;
}
function AddNewSeriesModal({
isOpen,
onModalClose,
...otherProps
}: AddNewSeriesModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<AddNewSeriesModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default AddNewSeriesModal;

@ -1,300 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
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 Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import SeriesPoster from 'Series/SeriesPoster';
import * as seriesTypes from 'Utilities/Series/seriesTypes';
import translate from 'Utilities/String/translate';
import styles from './AddNewSeriesModalContent.css';
class AddNewSeriesModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
seriesType: props.initialSeriesType === seriesTypes.STANDARD ?
props.seriesType.value :
props.initialSeriesType
};
}
componentDidUpdate(prevProps) {
if (this.props.seriesType.value !== prevProps.seriesType.value) {
this.setState({ seriesType: this.props.seriesType.value });
}
}
//
// Listeners
onQualityProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
};
onAddSeriesPress = () => {
const {
seriesType
} = this.state;
this.props.onAddSeriesPress(
seriesType
);
};
//
// Render
render() {
const {
title,
year,
overview,
images,
isAdding,
rootFolderPath,
monitor,
qualityProfileId,
seriesType,
seasonFolder,
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
folder,
tags,
isSmallScreen,
isWindows,
onModalClose,
onInputChange,
...otherProps
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
{
!title.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
isSmallScreen ?
null :
<div className={styles.poster}>
<SeriesPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
{
overview ?
<div className={styles.overview}>
{overview}
</div> :
null
}
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
seriesFolder: folder,
isWindows
}}
selectedValueOptions={{
seriesFolder: folder,
isWindows
}}
helpText={translate('AddNewSeriesRootFolderHelpText', { folder })}
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Monitor')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MonitoringOptions')}
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('SeriesType')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('SeriesTypes')}
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
onChange={onInputChange}
{...seriesType}
value={this.state.seriesType}
helpText={translate('SeriesTypesHelpText')}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
onChange={onInputChange}
{...seasonFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForMissingEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForMissingEpisodes"
onChange={onInputChange}
{...searchForMissingEpisodes}
/>
</label>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForCutoffUnmetEpisodes"
onChange={onInputChange}
{...searchForCutoffUnmetEpisodes}
/>
</label>
</div>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddSeriesPress}
>
{translate('AddSeriesWithTitle', { title })}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewSeriesModalContent.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
overview: PropTypes.string,
initialSeriesType: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
seriesType: PropTypes.object.isRequired,
seasonFolder: PropTypes.object.isRequired,
searchForMissingEpisodes: PropTypes.object.isRequired,
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
tags: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
isWindows: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired,
onAddSeriesPress: PropTypes.func.isRequired
};
export default AddNewSeriesModalContent;

@ -0,0 +1,301 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
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 Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import SeriesPoster from 'Series/SeriesPoster';
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './AddNewSeriesModalContent.css';
export interface AddNewSeriesModalContentProps
extends Pick<
AddSeries,
'tvdbId' | 'title' | 'year' | 'overview' | 'images' | 'folder'
> {
initialSeriesType: string;
onModalClose: () => void;
}
function AddNewSeriesModalContent({
tvdbId,
title,
year,
overview,
images,
folder,
initialSeriesType,
onModalClose,
}: AddNewSeriesModalContentProps) {
const dispatch = useDispatch();
const { isAdding, addError, defaults } = useSelector(
(state: AppState) => state.addSeries
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(defaults, {}, addError);
}, [defaults, addError]);
const [seriesType, setSeriesType] = useState(
initialSeriesType === 'standard'
? settings.seriesType.value
: initialSeriesType
);
const {
monitor,
qualityProfileId,
rootFolderPath,
searchForCutoffUnmetEpisodes,
searchForMissingEpisodes,
seasonFolder,
seriesType: seriesTypeSetting,
tags,
} = settings;
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(setAddSeriesDefault({ [name]: value }));
},
[dispatch]
);
const handleQualityProfileIdChange = useCallback(
({ value }: InputChanged<string | number>) => {
dispatch(setAddSeriesDefault({ qualityProfileId: value }));
},
[dispatch]
);
const handleAddSeriesPress = useCallback(() => {
dispatch(
addSeries({
tvdbId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
})
);
}, [
tvdbId,
seriesType,
rootFolderPath,
monitor,
qualityProfileId,
seasonFolder,
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
tags,
dispatch,
]);
useEffect(() => {
setSeriesType(seriesTypeSetting.value);
}, [seriesTypeSetting]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
{!title.includes(String(year)) && year ? (
<span className={styles.year}>({year})</span>
) : null}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{isSmallScreen ? null : (
<div className={styles.poster}>
<SeriesPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
)}
<div className={styles.info}>
{overview ? (
<div className={styles.overview}>{overview}</div>
) : null}
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
seriesFolder: folder,
isWindows,
}}
selectedValueOptions={{
seriesFolder: folder,
isWindows,
}}
helpText={translate('AddNewSeriesRootFolderHelpText', {
folder,
})}
onChange={handleInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Monitor')}
<Popover
anchor={
<Icon className={styles.labelIcon} name={icons.INFO} />
}
title={translate('MonitoringOptions')}
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
onChange={handleInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={handleQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('SeriesType')}
<Popover
anchor={
<Icon className={styles.labelIcon} name={icons.INFO} />
}
title={translate('SeriesTypes')}
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
onChange={handleInputChange}
{...seriesTypeSetting}
value={seriesType}
helpText={translate('SeriesTypesHelpText')}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
onChange={handleInputChange}
{...seasonFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={handleInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForMissingEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForMissingEpisodes"
onChange={handleInputChange}
{...searchForMissingEpisodes}
/>
</label>
<label className={styles.searchLabelContainer}>
<span className={styles.searchLabel}>
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
</span>
<CheckInput
containerClassName={styles.searchInputContainer}
className={styles.searchInput}
name="searchForCutoffUnmetEpisodes"
onChange={handleInputChange}
{...searchForCutoffUnmetEpisodes}
/>
</label>
</div>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={handleAddSeriesPress}
>
{translate('AddSeriesWithTitle', { title })}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
export default AddNewSeriesModalContent;

@ -1,110 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewSeriesModalContent from './AddNewSeriesModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
createDimensionsSelector(),
createSystemStatusSelector(),
(addSeriesState, dimensions, systemStatus) => {
const {
isAdding,
addError,
defaults
} = addSeriesState;
const {
settings,
validationErrors,
validationWarnings
} = selectSettings(defaults, {}, addError);
return {
isAdding,
addError,
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
isWindows: systemStatus.isWindows,
...settings
};
}
);
}
const mapDispatchToProps = {
setAddSeriesDefault,
addSeries
};
class AddNewSeriesModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setAddSeriesDefault({ [name]: value });
};
onAddSeriesPress = (seriesType) => {
const {
tvdbId,
rootFolderPath,
monitor,
qualityProfileId,
seasonFolder,
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
tags
} = this.props;
this.props.addSeries({
tvdbId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value
});
};
//
// Render
render() {
return (
<AddNewSeriesModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddSeriesPress={this.onAddSeriesPress}
/>
);
}
}
AddNewSeriesModalContentConnector.propTypes = {
tvdbId: PropTypes.number.isRequired,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
seriesType: PropTypes.object.isRequired,
seasonFolder: PropTypes.object.isRequired,
searchForMissingEpisodes: PropTypes.object.isRequired,
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onModalClose: PropTypes.func.isRequired,
setAddSeriesDefault: PropTypes.func.isRequired,
addSeries: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);

@ -1,276 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MetadataAttribution from 'Components/MetadataAttribution';
import { icons, kinds, sizes } from 'Helpers/Props';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
class AddNewSeriesSearchResult extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isNewAddSeriesModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
this.onAddSeriesModalClose();
}
}
//
// Listeners
onPress = () => {
this.setState({ isNewAddSeriesModalOpen: true });
};
onAddSeriesModalClose = () => {
this.setState({ isNewAddSeriesModalOpen: false });
};
onTVDBLinkPress = (event) => {
event.stopPropagation();
};
//
// Render
render() {
const {
tvdbId,
title,
titleSlug,
year,
network,
originalLanguage,
genres,
status,
overview,
statistics,
ratings,
folder,
seriesType,
images,
isExistingSeries,
isSmallScreen
} = this.props;
const seasonCount = statistics.seasonCount;
const {
isNewAddSeriesModalOpen
} = this.state;
const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress };
let seasons = translate('OneSeason');
if (seasonCount > 1) {
seasons = translate('CountSeasons', { count: seasonCount });
}
return (
<div className={styles.searchResult}>
<Link
className={styles.underlay}
{...linkProps}
/>
<div className={styles.overlay}>
{
isSmallScreen ?
null :
<SeriesPoster
className={styles.poster}
images={images}
size={250}
overflow={true}
lazy={false}
/>
}
<div className={styles.content}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{
!title.contains(year) && year ?
<span className={styles.year}>
({year})
</span> :
null
}
</div>
</div>
<div className={styles.icons}>
{
isExistingSeries ?
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
title={translate('AlreadyInYourLibrary')}
/> :
null
}
<Link
className={styles.tvdbLink}
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
onPress={this.onTVDBLinkPress}
>
<Icon
className={styles.tvdbLinkIcon}
name={icons.EXTERNAL_LINK}
size={28}
/>
</Link>
</div>
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
votes={ratings.votes}
iconSize={13}
/>
</Label>
{
originalLanguage?.name ?
<Label size={sizes.LARGE}>
<Icon
name={icons.LANGUAGE}
size={13}
/>
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span>
</Label> :
null
}
{
network ?
<Label size={sizes.LARGE}>
<Icon
name={icons.NETWORK}
size={13}
/>
<span className={styles.network}>
{network}
</span>
</Label> :
null
}
{
genres.length > 0 ?
<Label size={sizes.LARGE}>
<Icon
name={icons.GENRE}
size={13}
/>
<SeriesGenres className={styles.genres} genres={genres} />
</Label> :
null
}
{
seasonCount ?
<Label size={sizes.LARGE}>
{seasons}
</Label> :
null
}
{
status === 'ended' ?
<Label
kind={kinds.DANGER}
size={sizes.LARGE}
>
{translate('Ended')}
</Label> :
null
}
{
status === 'upcoming' ?
<Label
kind={kinds.INFO}
size={sizes.LARGE}
>
{translate('Upcoming')}
</Label> :
null
}
</div>
<div className={styles.overview}>
{overview}
</div>
<MetadataAttribution />
</div>
</div>
<AddNewSeriesModal
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
tvdbId={tvdbId}
title={title}
year={year}
overview={overview}
folder={folder}
initialSeriesType={seriesType}
images={images}
onModalClose={this.onAddSeriesModalClose}
/>
</div>
);
}
}
AddNewSeriesSearchResult.propTypes = {
tvdbId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
originalLanguage: PropTypes.object,
genres: PropTypes.arrayOf(PropTypes.string),
status: PropTypes.string.isRequired,
overview: PropTypes.string,
statistics: PropTypes.object.isRequired,
ratings: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
seriesType: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingSeries: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired
};
AddNewSeriesSearchResult.defaultProps = {
genres: []
};
export default AddNewSeriesSearchResult;

@ -0,0 +1,184 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { AddSeries } from 'App/State/AddSeriesAppState';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MetadataAttribution from 'Components/MetadataAttribution';
import { icons, kinds, sizes } from 'Helpers/Props';
import { Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
type AddNewSeriesSearchResultProps = AddSeries;
function AddNewSeriesSearchResult({
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
folder,
overview,
seriesType,
images,
}: AddNewSeriesSearchResultProps) {
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const { isSmallScreen } = useSelector(createDimensionsSelector());
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
const seasonCount = statistics.seasonCount;
const handlePress = useCallback(() => {
setIsNewAddSeriesModalOpen(true);
}, []);
const handleAddSeriesModalClose = useCallback(() => {
setIsNewAddSeriesModalOpen(false);
}, []);
const handleTvdbLinkPress = useCallback((event: React.SyntheticEvent) => {
event.stopPropagation();
}, []);
const linkProps = isExistingSeries
? { to: `/series/${titleSlug}` }
: { onPress: handlePress };
let seasons = translate('OneSeason');
if (seasonCount > 1) {
seasons = translate('CountSeasons', { count: seasonCount });
}
return (
<div className={styles.searchResult}>
<Link className={styles.underlay} {...linkProps} />
<div className={styles.overlay}>
{isSmallScreen ? null : (
<SeriesPoster
className={styles.poster}
images={images}
size={250}
overflow={true}
lazy={false}
/>
)}
<div className={styles.content}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{!title.includes(String(year)) && year ? (
<span className={styles.year}>({year})</span>
) : null}
</div>
</div>
<div className={styles.icons}>
{isExistingSeries ? (
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
title={translate('AlreadyInYourLibrary')}
/>
) : null}
<Link
className={styles.tvdbLink}
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
onPress={handleTvdbLinkPress}
>
<Icon
className={styles.tvdbLinkIcon}
name={icons.EXTERNAL_LINK}
size={28}
/>
</Link>
</div>
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
votes={ratings.votes}
iconSize={13}
/>
</Label>
{originalLanguage?.name ? (
<Label size={sizes.LARGE}>
<Icon name={icons.LANGUAGE} size={13} />
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span>
</Label>
) : null}
{network ? (
<Label size={sizes.LARGE}>
<Icon name={icons.NETWORK} size={13} />
<span className={styles.network}>{network}</span>
</Label>
) : null}
{genres.length > 0 ? (
<Label size={sizes.LARGE}>
<Icon name={icons.GENRE} size={13} />
<SeriesGenres className={styles.genres} genres={genres} />
</Label>
) : null}
{seasonCount ? <Label size={sizes.LARGE}>{seasons}</Label> : null}
{status === 'ended' ? (
<Label kind={kinds.DANGER} size={sizes.LARGE}>
{translate('Ended')}
</Label>
) : null}
{status === 'upcoming' ? (
<Label kind={kinds.INFO} size={sizes.LARGE}>
{translate('Upcoming')}
</Label>
) : null}
</div>
<div className={styles.overview}>{overview}</div>
<MetadataAttribution />
</div>
</div>
<AddNewSeriesModal
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
tvdbId={tvdbId}
title={title}
year={year}
overview={overview}
folder={folder}
initialSeriesType={seriesType}
images={images}
onModalClose={handleAddSeriesModalClose}
/>
</div>
);
}
export default AddNewSeriesSearchResult;

@ -1,20 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingSeriesSelector(),
createDimensionsSelector(),
(isExistingSeries, dimensions) => {
return {
isExistingSeries,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(AddNewSeriesSearchResult);

@ -3,7 +3,7 @@ import { Redirect, Route } from 'react-router-dom';
import Blocklist from 'Activity/Blocklist/Blocklist';
import History from 'Activity/History/History';
import Queue from 'Activity/Queue/Queue';
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
import AddNewSeries from 'AddSeries/AddNewSeries/AddNewSeries';
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
import CalendarPage from 'Calendar/CalendarPage';
import NotFound from 'Components/NotFound';
@ -58,7 +58,7 @@ function AppRoutes() {
/>
)}
<Route path="/add/new" component={AddNewSeriesConnector} />
<Route path="/add/new" component={AddNewSeries} />
<Route path="/add/import" component={ImportSeries} />

@ -0,0 +1,27 @@
import AppSectionState, { Error } from 'App/State/AppSectionState';
import Language from 'Language/Language';
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeries extends Series {
folder: string;
}
interface AddSeriesAppState extends AppSectionState<AddSeries> {
isAdding: boolean;
isAdded: boolean;
addError: Error | undefined;
defaults: {
rootFolderPath: string;
monitor: SeriesMonitor;
qualityProfileId: number;
seriesType: SeriesType;
seasonFolder: boolean;
language: Language;
tags: number[];
searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean;
};
}
export default AddSeriesAppState;

@ -1,6 +1,7 @@
import ModelBase from 'App/ModelBase';
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
import AddSeriesAppState from './AddSeriesAppState';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
@ -81,6 +82,7 @@ export interface AppSectionState {
}
interface AppState {
addSeries: AddSeriesAppState;
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;

@ -44,6 +44,7 @@
.optionsContainer {
z-index: $popperZIndex;
max-height: vh(50);
width: auto;
}

@ -29,6 +29,8 @@ import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 30;
function isArrowKey(keyCode: number) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@ -193,9 +195,10 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
const windowHeight = window.innerHeight;
if (/^bottom/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
data.styles.maxHeight =
windowHeight - bottom - MINIMUM_DISTANCE_FROM_EDGE;
} else {
data.styles.maxHeight = top;
data.styles.maxHeight = top - MINIMUM_DISTANCE_FROM_EDGE;
}
return data;

@ -7,12 +7,13 @@ import EnhancedSelectInput, {
EnhancedSelectInputValue,
} from './EnhancedSelectInput';
interface LanguageSelectInputOnChangeProps {
export interface LanguageSelectInputOnChangeProps {
name: string;
value: number | string | Language;
}
export interface LanguageSelectInputProps {
className?: string;
name: string;
value: number | string | Language;
includeNoChange?: boolean;

@ -1,54 +1,76 @@
import { useQuery } from '@tanstack/react-query';
import { UndefinedInitialDataOptions, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
interface QueryOptions {
url: string;
headers?: HeadersInit;
interface ApiErrorResponse {
message: string;
details: string;
}
const absUrlRegex = /^(https?:)?\/\//i;
const apiRoot = window.Sonarr.apiRoot;
export class ApiError extends Error {
public statusCode: number;
public statusText: string;
public statusBody?: ApiErrorResponse;
function isAbsolute(url: string) {
return absUrlRegex.test(url);
}
public constructor(
path: string,
statusCode: number,
statusText: string,
statusBody?: ApiErrorResponse
) {
super(`Request Error: (${statusCode}) ${path}`);
this.statusCode = statusCode;
this.statusText = statusText;
this.statusBody = statusBody;
function getUrl(url: string) {
return apiRoot + url;
Object.setPrototypeOf(this, new.target.prototype);
}
}
function useApiQuery<T>(options: QueryOptions) {
const { url, headers } = options;
interface QueryOptions<T> {
path: string;
headers?: HeadersInit;
queryOptions?:
| Omit<UndefinedInitialDataOptions<T, ApiError>, 'queryKey' | 'queryFn'>
| undefined;
}
const final = useMemo(() => {
if (isAbsolute(url)) {
return {
url,
headers,
};
}
const apiRoot = window.Sonarr.apiRoot;
function useApiQuery<T>(options: QueryOptions<T>) {
const { path, headers } = useMemo(() => {
return {
url: getUrl(url),
path: apiRoot + options.path,
headers: {
...headers,
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
};
}, [url, headers]);
}, [options]);
return useQuery({
queryKey: [final.url],
queryFn: async () => {
const result = await fetch(final.url, {
headers: final.headers,
...options.queryOptions,
queryKey: [path, headers],
queryFn: async ({ signal }) => {
const response = await fetch(path, {
headers,
signal,
});
if (!result.ok) {
throw new Error('Failed to fetch');
if (!response.ok) {
// eslint-disable-next-line init-declarations
let body;
try {
body = (await response.json()) as ApiErrorResponse;
} catch {
throw new ApiError(path, response.status, response.statusText);
}
throw new ApiError(path, response.status, response.statusText, body);
}
return result.json() as T;
return response.json() as T;
},
});
}

@ -0,0 +1,26 @@
import { useEffect, useRef, useState } from 'react';
function useDebounce<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const valueTimeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (delay === 0) {
setDebouncedValue(value);
clearTimeout(valueTimeout.current);
return;
}
valueTimeout.current = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(valueTimeout.current);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;

@ -0,0 +1,19 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router';
function useQueryParams<T>() {
const { search } = useLocation();
return useMemo(() => {
const searchParams = new URLSearchParams(search);
return searchParams.entries().reduce<T>((acc, [key, value]) => {
return {
...acc,
[key]: value,
};
}, {} as T);
}, [search]);
}
export default useQueryParams;

@ -37,7 +37,7 @@ function RootFolderModalContent(props: RootFolderModalContentProps) {
const [rootFolderPath, setRootFolderPath] = useState(props.rootFolderPath);
const { isLoading, data } = useApiQuery<SeriesFolder>({
url: `/series/${seriesId}/folder`,
path: `/series/${seriesId}/folder`,
});
const onInputChange = useCallback(({ value }: InputChanged<string>) => {

@ -1,16 +1,11 @@
import { some } from 'lodash';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import createAllSeriesSelector from './createAllSeriesSelector';
function createExistingSeriesSelector() {
return createSelector(
(_: AppState, { tvdbId }: { tvdbId: number }) => tvdbId,
createAllSeriesSelector(),
(tvdbId, series) => {
function createExistingSeriesSelector(tvdbId: number) {
return createSelector(createAllSeriesSelector(), (series) => {
return some(series, { tvdbId });
}
);
});
}
export default createExistingSeriesSelector;

@ -1,12 +1,25 @@
import { Error } from 'App/State/AppSectionState';
import { ApiError } from 'Helpers/Hooks/useApiQuery';
function getErrorMessage(xhr: Error, fallbackErrorMessage = '') {
if (!xhr || !xhr.responseJSON) {
function getErrorMessage(error: Error | ApiError, fallbackErrorMessage = '') {
if (!error) {
return fallbackErrorMessage;
}
if ('message' in xhr.responseJSON && xhr.responseJSON.message) {
return xhr.responseJSON.message;
if (error instanceof ApiError) {
if (!error.statusBody) {
return fallbackErrorMessage;
}
return error.statusBody.message;
}
if (!error.responseJSON) {
return fallbackErrorMessage;
}
if ('message' in error.responseJSON && error.responseJSON.message) {
return error.responseJSON.message;
}
return fallbackErrorMessage;

Loading…
Cancel
Save