From 37cc66ce66d43313dee3c94384467aff7d6c22a8 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 11 Jan 2025 21:39:48 -0800 Subject: [PATCH] Convert Add New Series to TypeScript --- .../AddSeries/AddNewSeries/AddNewSeries.js | 215 ------------- .../AddSeries/AddNewSeries/AddNewSeries.tsx | 153 +++++++++ .../AddNewSeries/AddNewSeriesConnector.js | 104 ------ .../AddNewSeries/AddNewSeriesModal.js | 31 -- .../AddNewSeries/AddNewSeriesModal.tsx | 23 ++ .../AddNewSeries/AddNewSeriesModalContent.js | 300 ----------------- .../AddNewSeries/AddNewSeriesModalContent.tsx | 301 ++++++++++++++++++ .../AddNewSeriesModalContentConnector.js | 110 ------- .../AddNewSeries/AddNewSeriesSearchResult.js | 276 ---------------- .../AddNewSeries/AddNewSeriesSearchResult.tsx | 184 +++++++++++ .../AddNewSeriesSearchResultConnector.js | 20 -- frontend/src/App/AppRoutes.tsx | 4 +- frontend/src/App/State/AddSeriesAppState.ts | 27 ++ frontend/src/App/State/AppState.ts | 2 + .../Form/Select/EnhancedSelectInput.css | 1 + .../Form/Select/EnhancedSelectInput.tsx | 7 +- .../Form/Select/LanguageSelectInput.tsx | 3 +- frontend/src/Helpers/Hooks/useApiQuery.ts | 82 +++-- frontend/src/Helpers/Hooks/useDebounce.ts | 26 ++ frontend/src/Helpers/Hooks/useQueryParams.ts | 19 ++ .../RootFolder/RootFolderModalContent.tsx | 2 +- .../Selectors/createExistingSeriesSelector.ts | 13 +- .../src/Utilities/Object/getErrorMessage.ts | 21 +- 23 files changed, 819 insertions(+), 1105 deletions(-) delete mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeries.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx delete mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js delete mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx delete mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx delete mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js delete mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx delete mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js create mode 100644 frontend/src/App/State/AddSeriesAppState.ts create mode 100644 frontend/src/Helpers/Hooks/useDebounce.ts create mode 100644 frontend/src/Helpers/Hooks/useQueryParams.ts diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js deleted file mode 100644 index 18cbffddb..000000000 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js +++ /dev/null @@ -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 ( - - -
-
- -
- - - - -
- - { - isFetching && - - } - - { - !isFetching && !!error ? -
-
- {translate('AddNewSeriesError')} -
- - {getErrorMessage(error)} -
: null - } - - { - !isFetching && !error && !!items.length && -
- { - items.map((item) => { - return ( - - ); - }) - } -
- } - - { - !isFetching && !error && !items.length && !!term && -
-
{translate('CouldNotFindResults', { term })}
-
{translate('SearchByTvdbId')}
-
- - {translate('WhyCantIFindMyShow')} - -
-
- } - - { - term ? - null : -
-
- {translate('AddNewSeriesHelpText')} -
-
{translate('SearchByTvdbId')}
-
- } - - { - !term && !hasExistingSeries ? -
-
- {translate('NoSeriesHaveBeenAdded')} -
-
- -
-
: - null - } - -
- - - ); - } -} - -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; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx new file mode 100644 index 000000000..0130b3d90 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx @@ -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) => { + setTerm(value); + setIsFetching(!!value.trim()); + }, + [] + ); + + const handleClearSeriesLookupPress = useCallback(() => { + setTerm(''); + setIsFetching(false); + }, []); + + const { + isFetching: isFetchingApi, + error, + data = [], + } = useApiQuery({ + path: `/series/lookup?term=${query}`, + queryOptions: { + enabled: !!query, + }, + }); + + useEffect(() => { + setIsFetching(isFetchingApi); + }, [isFetchingApi]); + + useEffect(() => { + setTerm(initialTerm); + }, [initialTerm]); + + return ( + + +
+
+ +
+ + + + +
+ + {isFetching ? : null} + + {!isFetching && !!error ? ( +
+
+ {translate('AddNewSeriesError')} +
+ + {getErrorMessage(error)} +
+ ) : null} + + {!isFetching && !error && !!data.length ? ( +
+ {data.map((item) => { + return ; + })} +
+ ) : null} + + {!isFetching && !error && !data.length && term ? ( +
+
+ {translate('CouldNotFindResults', { term })} +
+
{translate('SearchByTvdbId')}
+
+ + {translate('WhyCantIFindMyShow')} + +
+
+ ) : null} + + {term ? null : ( +
+
+ {translate('AddNewSeriesHelpText')} +
+
{translate('SearchByTvdbId')}
+
+ )} + + {!term && !seriesCount ? ( +
+
+ {translate('NoSeriesHaveBeenAdded')} +
+
+ +
+
+ ) : null} + +
+ + + ); +} + +export default AddNewSeries; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js deleted file mode 100644 index a80a94fca..000000000 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -AddNewSeriesConnector.propTypes = { - term: PropTypes.string, - lookupSeries: PropTypes.func.isRequired, - clearAddSeries: PropTypes.func.isRequired, - fetchRootFolders: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector); diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js deleted file mode 100644 index cb603e7a6..000000000 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js +++ /dev/null @@ -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 ( - - - - ); -} - -AddNewSeriesModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AddNewSeriesModal; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx new file mode 100644 index 000000000..e470da57c --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx @@ -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 ( + + + + ); +} + +export default AddNewSeriesModal; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js deleted file mode 100644 index 285c00ecb..000000000 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js +++ /dev/null @@ -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 ( - - - {title} - - { - !title.contains(year) && !!year && - ({year}) - } - - - -
- { - isSmallScreen ? - null : -
- -
- } - -
- { - overview ? -
- {overview} -
: - null - } - -
- - {translate('RootFolder')} - - - - - - - {translate('Monitor')} - - - } - title={translate('MonitoringOptions')} - body={} - position={tooltipPositions.RIGHT} - /> - - - - - - - {translate('QualityProfile')} - - - - - - - {translate('SeriesType')} - - - } - title={translate('SeriesTypes')} - body={} - position={tooltipPositions.RIGHT} - /> - - - - - - - {translate('SeasonFolder')} - - - - - - {translate('Tags')} - - - -
-
-
-
- - -
- - - -
- - - {translate('AddSeriesWithTitle', { title })} - -
-
- ); - } -} - -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; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx new file mode 100644 index 000000000..5368586ce --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx @@ -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) => { + 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 ( + + + {title} + + {!title.includes(String(year)) && year ? ( + ({year}) + ) : null} + + + +
+ {isSmallScreen ? null : ( +
+ +
+ )} + +
+ {overview ? ( +
{overview}
+ ) : null} + +
+ + {translate('RootFolder')} + + + + + + + {translate('Monitor')} + + + } + title={translate('MonitoringOptions')} + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + {translate('QualityProfile')} + + + + + + + {translate('SeriesType')} + + + } + title={translate('SeriesTypes')} + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + {translate('SeasonFolder')} + + + + + + {translate('Tags')} + + + +
+
+
+
+ + +
+ + + +
+ + + {translate('AddSeriesWithTitle', { title })} + +
+
+ ); +} + +export default AddNewSeriesModalContent; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js deleted file mode 100644 index f40b3a2f1..000000000 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -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); diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js deleted file mode 100644 index 8ce556456..000000000 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js +++ /dev/null @@ -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 ( -
- - -
- { - isSmallScreen ? - null : - - } - -
-
-
-
- {title} - - { - !title.contains(year) && year ? - - ({year}) - : - null - } -
-
- -
- { - isExistingSeries ? - : - null - } - - - - -
-
- -
- - - { - originalLanguage?.name ? - : - null - } - - { - network ? - : - null - } - - { - genres.length > 0 ? - : - null - } - - { - seasonCount ? - : - null - } - - { - status === 'ended' ? - : - null - } - - { - status === 'upcoming' ? - : - null - } -
- -
- {overview} -
- - -
-
- - -
- ); - } -} - -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; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx new file mode 100644 index 000000000..69d31f721 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx @@ -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 ( +
+ + +
+ {isSmallScreen ? null : ( + + )} + +
+
+
+
+ {title} + + {!title.includes(String(year)) && year ? ( + ({year}) + ) : null} +
+
+ +
+ {isExistingSeries ? ( + + ) : null} + + + + +
+
+ +
+ + + {originalLanguage?.name ? ( + + ) : null} + + {network ? ( + + ) : null} + + {genres.length > 0 ? ( + + ) : null} + + {seasonCount ? : null} + + {status === 'ended' ? ( + + ) : null} + + {status === 'upcoming' ? ( + + ) : null} +
+ +
{overview}
+ + +
+
+ + +
+ ); +} + +export default AddNewSeriesSearchResult; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js deleted file mode 100644 index c1207c9ef..000000000 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js +++ /dev/null @@ -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); diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index b36415486..ba613de38 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -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() { /> )} - + diff --git a/frontend/src/App/State/AddSeriesAppState.ts b/frontend/src/App/State/AddSeriesAppState.ts new file mode 100644 index 000000000..54a8c260a --- /dev/null +++ b/frontend/src/App/State/AddSeriesAppState.ts @@ -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 { + 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; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 7ce330f7f..ce60b6b20 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -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; diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.css b/frontend/src/Components/Form/Select/EnhancedSelectInput.css index 735d63573..27b12eb5a 100644 --- a/frontend/src/Components/Form/Select/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.css @@ -44,6 +44,7 @@ .optionsContainer { z-index: $popperZIndex; + max-height: vh(50); width: auto; } diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx index 8c1b4a3d2..c0da16382 100644 --- a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx +++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx @@ -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, 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; diff --git a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx index 2ea9e8093..fab6d9ee9 100644 --- a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx +++ b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx @@ -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; diff --git a/frontend/src/Helpers/Hooks/useApiQuery.ts b/frontend/src/Helpers/Hooks/useApiQuery.ts index 69c5c8672..0cc1196b4 100644 --- a/frontend/src/Helpers/Hooks/useApiQuery.ts +++ b/frontend/src/Helpers/Hooks/useApiQuery.ts @@ -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}`); -function getUrl(url: string) { - return apiRoot + url; + this.statusCode = statusCode; + this.statusText = statusText; + this.statusBody = statusBody; + + Object.setPrototypeOf(this, new.target.prototype); + } } -function useApiQuery(options: QueryOptions) { - const { url, headers } = options; +interface QueryOptions { + path: string; + headers?: HeadersInit; + queryOptions?: + | Omit, 'queryKey' | 'queryFn'> + | undefined; +} - const final = useMemo(() => { - if (isAbsolute(url)) { - return { - url, - headers, - }; - } +const apiRoot = window.Sonarr.apiRoot; +function useApiQuery(options: QueryOptions) { + 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; }, }); } diff --git a/frontend/src/Helpers/Hooks/useDebounce.ts b/frontend/src/Helpers/Hooks/useDebounce.ts new file mode 100644 index 000000000..142989d97 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useDebounce.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef, useState } from 'react'; + +function useDebounce(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + const valueTimeout = useRef>(); + + 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; diff --git a/frontend/src/Helpers/Hooks/useQueryParams.ts b/frontend/src/Helpers/Hooks/useQueryParams.ts new file mode 100644 index 000000000..9110a4bb5 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useQueryParams.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router'; + +function useQueryParams() { + const { search } = useLocation(); + + return useMemo(() => { + const searchParams = new URLSearchParams(search); + + return searchParams.entries().reduce((acc, [key, value]) => { + return { + ...acc, + [key]: value, + }; + }, {} as T); + }, [search]); +} + +export default useQueryParams; diff --git a/frontend/src/Series/Edit/RootFolder/RootFolderModalContent.tsx b/frontend/src/Series/Edit/RootFolder/RootFolderModalContent.tsx index d53d5e306..28385630f 100644 --- a/frontend/src/Series/Edit/RootFolder/RootFolderModalContent.tsx +++ b/frontend/src/Series/Edit/RootFolder/RootFolderModalContent.tsx @@ -37,7 +37,7 @@ function RootFolderModalContent(props: RootFolderModalContentProps) { const [rootFolderPath, setRootFolderPath] = useState(props.rootFolderPath); const { isLoading, data } = useApiQuery({ - url: `/series/${seriesId}/folder`, + path: `/series/${seriesId}/folder`, }); const onInputChange = useCallback(({ value }: InputChanged) => { diff --git a/frontend/src/Store/Selectors/createExistingSeriesSelector.ts b/frontend/src/Store/Selectors/createExistingSeriesSelector.ts index ad84c3558..d9080c75b 100644 --- a/frontend/src/Store/Selectors/createExistingSeriesSelector.ts +++ b/frontend/src/Store/Selectors/createExistingSeriesSelector.ts @@ -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) => { - return some(series, { tvdbId }); - } - ); +function createExistingSeriesSelector(tvdbId: number) { + return createSelector(createAllSeriesSelector(), (series) => { + return some(series, { tvdbId }); + }); } export default createExistingSeriesSelector; diff --git a/frontend/src/Utilities/Object/getErrorMessage.ts b/frontend/src/Utilities/Object/getErrorMessage.ts index b250b1258..17abeccd2 100644 --- a/frontend/src/Utilities/Object/getErrorMessage.ts +++ b/frontend/src/Utilities/Object/getErrorMessage.ts @@ -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;