diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js deleted file mode 100644 index eecdf8495..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js +++ /dev/null @@ -1,179 +0,0 @@ -import { reduce } from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import ImportSeriesFooterConnector from './ImportSeriesFooterConnector'; -import ImportSeriesTableConnector from './ImportSeriesTableConnector'; - -class ImportSeries extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.scrollerRef = React.createRef(); - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {} - }; - } - - // - // Listeners - - getSelectedIds = () => { - return reduce( - this.state.selectedState, - (result, value, id) => { - if (value) { - result.push(id); - } - - return result; - }, - [] - ); - }; - - onSelectAllChange = ({ value }) => { - // Only select non-dupes - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onRemoveSelectedStateItem = (id) => { - this.setState((state) => { - const selectedState = Object.assign({}, state.selectedState); - delete selectedState[id]; - - return { - ...state, - selectedState - }; - }); - }; - - onInputChange = ({ name, value }) => { - this.props.onInputChange(this.getSelectedIds(), name, value); - }; - - onImportPress = () => { - this.props.onImportPress(this.getSelectedIds()); - }; - - // - // Render - - render() { - const { - rootFolderId, - path, - rootFoldersFetching, - rootFoldersPopulated, - rootFoldersError, - unmappedFolders - } = this.props; - - const { - allSelected, - allUnselected, - selectedState - } = this.state; - - return ( - - - { - rootFoldersFetching ? : null - } - - { - !rootFoldersFetching && !!rootFoldersError ? - - {translate('RootFoldersLoadError')} - : - null - } - - { - !rootFoldersError && - !rootFoldersFetching && - rootFoldersPopulated && - !unmappedFolders.length ? - - {translate('AllSeriesInRootFolderHaveBeenImported', { path })} - : - null - } - - { - !rootFoldersError && - !rootFoldersFetching && - rootFoldersPopulated && - !!unmappedFolders.length && - this.scrollerRef.current ? - : - null - } - - - { - !rootFoldersError && - !rootFoldersFetching && - !!unmappedFolders.length ? - : - null - } - - ); - } -} - -ImportSeries.propTypes = { - rootFolderId: PropTypes.number.isRequired, - path: PropTypes.string, - rootFoldersFetching: PropTypes.bool.isRequired, - rootFoldersPopulated: PropTypes.bool.isRequired, - rootFoldersError: PropTypes.object, - unmappedFolders: PropTypes.arrayOf(PropTypes.object), - items: PropTypes.arrayOf(PropTypes.object), - onInputChange: PropTypes.func.isRequired, - onImportPress: PropTypes.func.isRequired -}; - -ImportSeries.defaultProps = { - unmappedFolders: [] -}; - -export default ImportSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx new file mode 100644 index 000000000..53fca5ba1 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router'; +import { SelectProvider } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { kinds } from 'Helpers/Props'; +import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions'; +import { clearImportSeries } from 'Store/Actions/importSeriesActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import translate from 'Utilities/String/translate'; +import ImportSeriesFooter from './ImportSeriesFooter'; +import ImportSeriesTable from './ImportSeriesTable'; + +function ImportSeries() { + const dispatch = useDispatch(); + const { rootFolderId: rootFolderIdString } = useParams<{ + rootFolderId: string; + }>(); + const rootFolderId = parseInt(rootFolderIdString); + + const { + isFetching: rootFoldersFetching, + isPopulated: rootFoldersPopulated, + error: rootFoldersError, + items: rootFolders, + } = useSelector((state: AppState) => state.rootFolders); + + const { path, unmappedFolders } = useMemo(() => { + const rootFolder = rootFolders.find((r) => r.id === rootFolderId); + + return { + path: rootFolder?.path ?? '', + unmappedFolders: + rootFolder?.unmappedFolders.map((unmappedFolders) => { + return { + ...unmappedFolders, + id: unmappedFolders.name, + }; + }) ?? [], + }; + }, [rootFolders, rootFolderId]); + + const qualityProfiles = useSelector( + (state: AppState) => state.settings.qualityProfiles.items + ); + + const defaultQualityProfileId = useSelector( + (state: AppState) => state.addSeries.defaults.qualityProfileId + ); + + const scrollerRef = useRef(null); + + const items = useMemo(() => { + return unmappedFolders.map((unmappedFolder) => { + return { + ...unmappedFolder, + id: unmappedFolder.name, + }; + }); + }, [unmappedFolders]); + + useEffect(() => { + dispatch(fetchRootFolders({ id: rootFolderId, timeout: false })); + + return () => { + dispatch(clearImportSeries()); + }; + }, [rootFolderId, dispatch]); + + useEffect(() => { + if ( + !defaultQualityProfileId || + !qualityProfiles.some((p) => p.id === defaultQualityProfileId) + ) { + dispatch( + setAddSeriesDefault({ qualityProfileId: qualityProfiles[0].id }) + ); + } + }, [defaultQualityProfileId, qualityProfiles, dispatch]); + + return ( + + + + {rootFoldersFetching ? : null} + + {!rootFoldersFetching && !!rootFoldersError ? ( + + {translate('RootFoldersLoadError')} + + ) : null} + + {!rootFoldersError && + !rootFoldersFetching && + rootFoldersPopulated && + !unmappedFolders.length ? ( + + {translate('AllSeriesInRootFolderHaveBeenImported', { path })} + + ) : null} + + {!rootFoldersError && + !rootFoldersFetching && + rootFoldersPopulated && + !!unmappedFolders.length && + scrollerRef.current ? ( + + ) : null} + + + {!rootFoldersError && + !rootFoldersFetching && + !!unmappedFolders.length ? ( + + ) : null} + + + ); +} + +export default ImportSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js deleted file mode 100644 index 50436ba88..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js +++ /dev/null @@ -1,153 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; -import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions'; -import { clearImportSeries, importSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import ImportSeries from './ImportSeries'; - -function createMapStateToProps() { - return createSelector( - (state, { match }) => match, - (state) => state.rootFolders, - (state) => state.addSeries, - (state) => state.importSeries, - (state) => state.settings.qualityProfiles, - ( - match, - rootFolders, - addSeries, - importSeriesState, - qualityProfiles - ) => { - const { - isFetching: rootFoldersFetching, - isPopulated: rootFoldersPopulated, - error: rootFoldersError, - items - } = rootFolders; - - const rootFolderId = parseInt(match.params.rootFolderId); - - const result = { - rootFolderId, - rootFoldersFetching, - rootFoldersPopulated, - rootFoldersError, - qualityProfiles: qualityProfiles.items, - defaultQualityProfileId: addSeries.defaults.qualityProfileId - }; - - if (items.length) { - const rootFolder = _.find(items, { id: rootFolderId }); - - return { - ...result, - ...rootFolder, - items: importSeriesState.items - }; - } - - return result; - } - ); -} - -const mapDispatchToProps = { - dispatchSetImportSeriesValue: setImportSeriesValue, - dispatchImportSeries: importSeries, - dispatchClearImportSeries: clearImportSeries, - dispatchFetchRootFolders: fetchRootFolders, - dispatchSetAddSeriesDefault: setAddSeriesDefault -}; - -class ImportSeriesConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - rootFolderId, - qualityProfiles, - defaultQualityProfileId, - dispatchFetchRootFolders, - dispatchSetAddSeriesDefault - } = this.props; - - dispatchFetchRootFolders({ id: rootFolderId, timeout: false }); - - let setDefaults = false; - const setDefaultPayload = {}; - - if ( - !defaultQualityProfileId || - !qualityProfiles.some((p) => p.id === defaultQualityProfileId) - ) { - setDefaults = true; - setDefaultPayload.qualityProfileId = qualityProfiles[0].id; - } - - if (setDefaults) { - dispatchSetAddSeriesDefault(setDefaultPayload); - } - } - - componentWillUnmount() { - this.props.dispatchClearImportSeries(); - } - - // - // Listeners - - onInputChange = (ids, name, value) => { - this.props.dispatchSetAddSeriesDefault({ [name]: value }); - - ids.forEach((id) => { - this.props.dispatchSetImportSeriesValue({ - id, - [name]: value - }); - }); - }; - - onImportPress = (ids) => { - this.props.dispatchImportSeries({ ids }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -const routeMatchShape = createRouteMatchShape({ - rootFolderId: PropTypes.string.isRequired -}); - -ImportSeriesConnector.propTypes = { - match: routeMatchShape.isRequired, - rootFolderId: PropTypes.number.isRequired, - rootFoldersFetching: PropTypes.bool.isRequired, - rootFoldersPopulated: PropTypes.bool.isRequired, - qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, - defaultQualityProfileId: PropTypes.number.isRequired, - dispatchSetImportSeriesValue: PropTypes.func.isRequired, - dispatchImportSeries: PropTypes.func.isRequired, - dispatchClearImportSeries: PropTypes.func.isRequired, - dispatchFetchRootFolders: PropTypes.func.isRequired, - dispatchSetAddSeriesDefault: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js deleted file mode 100644 index a2e4cffff..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js +++ /dev/null @@ -1,300 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CheckInput from 'Components/Form/CheckInput'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContentFooter from 'Components/Page/PageContentFooter'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './ImportSeriesFooter.css'; - -const MIXED = 'mixed'; - -class ImportSeriesFooter extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - defaultMonitor, - defaultQualityProfileId, - defaultSeasonFolder, - defaultSeriesType - } = props; - - this.state = { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - seriesType: defaultSeriesType, - seasonFolder: defaultSeasonFolder - }; - } - - componentDidUpdate(prevProps, prevState) { - const { - defaultMonitor, - defaultQualityProfileId, - defaultSeriesType, - defaultSeasonFolder, - isMonitorMixed, - isQualityProfileIdMixed, - isSeriesTypeMixed, - isSeasonFolderMixed - } = this.props; - - const { - monitor, - qualityProfileId, - seriesType, - seasonFolder - } = this.state; - - const newState = {}; - - if (isMonitorMixed && monitor !== MIXED) { - newState.monitor = MIXED; - } else if (!isMonitorMixed && monitor !== defaultMonitor) { - newState.monitor = defaultMonitor; - } - - if (isQualityProfileIdMixed && qualityProfileId !== MIXED) { - newState.qualityProfileId = MIXED; - } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) { - newState.qualityProfileId = defaultQualityProfileId; - } - - if (isSeriesTypeMixed && seriesType !== MIXED) { - newState.seriesType = MIXED; - } else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) { - newState.seriesType = defaultSeriesType; - } - - if (isSeasonFolderMixed && seasonFolder != null) { - newState.seasonFolder = null; - } else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) { - newState.seasonFolder = defaultSeasonFolder; - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.setState({ [name]: value }); - this.props.onInputChange({ name, value }); - }; - - // - // Render - - render() { - const { - selectedCount, - isImporting, - isLookingUpSeries, - isMonitorMixed, - isQualityProfileIdMixed, - isSeriesTypeMixed, - hasUnsearchedItems, - importError, - onImportPress, - onLookupPress, - onCancelLookupPress - } = this.props; - - const { - monitor, - qualityProfileId, - seriesType, - seasonFolder - } = this.state; - - return ( - - - - {translate('Monitor')} - - - - - - - - {translate('QualityProfile')} - - - - - - - - {translate('SeriesType')} - - - - - - - - {translate('SeasonFolder')} - - - - - - - - - - - - - {translate('ImportCountSeries', { selectedCount })} - - - { - isLookingUpSeries ? - - {translate('CancelProcessing')} - : - null - } - - { - hasUnsearchedItems ? - - {translate('StartProcessing')} - : - null - } - - { - isLookingUpSeries ? - : - null - } - - { - isLookingUpSeries ? - translate('ProcessingFolders') : - null - } - - { - importError ? - - } - title={translate('ImportErrors')} - body={ - - { - Array.isArray(importError.responseJSON) ? - importError.responseJSON.map((error, index) => { - return ( - - {error.errorMessage} - - ); - }) : - - { - JSON.stringify(importError.responseJSON) - } - - } - - } - position={tooltipPositions.RIGHT} - /> : - null - } - - - - ); - } -} - -ImportSeriesFooter.propTypes = { - selectedCount: PropTypes.number.isRequired, - isImporting: PropTypes.bool.isRequired, - isLookingUpSeries: PropTypes.bool.isRequired, - defaultMonitor: PropTypes.string.isRequired, - defaultQualityProfileId: PropTypes.number, - defaultSeriesType: PropTypes.string.isRequired, - defaultSeasonFolder: PropTypes.bool.isRequired, - isMonitorMixed: PropTypes.bool.isRequired, - isQualityProfileIdMixed: PropTypes.bool.isRequired, - isSeriesTypeMixed: PropTypes.bool.isRequired, - isSeasonFolderMixed: PropTypes.bool.isRequired, - hasUnsearchedItems: PropTypes.bool.isRequired, - importError: PropTypes.object, - onInputChange: PropTypes.func.isRequired, - onImportPress: PropTypes.func.isRequired, - onLookupPress: PropTypes.func.isRequired, - onCancelLookupPress: PropTypes.func.isRequired -}; - -export default ImportSeriesFooter; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx new file mode 100644 index 000000000..ef678e466 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx @@ -0,0 +1,310 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useSelect } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; +import CheckInput from 'Components/Form/CheckInput'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import Popover from 'Components/Tooltip/Popover'; +import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import { SeriesMonitor, SeriesType } from 'Series/Series'; +import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions'; +import { + cancelLookupSeries, + importSeries, + lookupUnsearchedSeries, + setImportSeriesValue, +} from 'Store/Actions/importSeriesActions'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import styles from './ImportSeriesFooter.css'; + +type MixedType = 'mixed'; + +function ImportSeriesFooter() { + const dispatch = useDispatch(); + const { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + seriesType: defaultSeriesType, + seasonFolder: defaultSeasonFolder, + } = useSelector((state: AppState) => state.addSeries.defaults); + + const { isLookingUpSeries, isImporting, items, importError } = useSelector( + (state: AppState) => state.importSeries + ); + + const [monitor, setMonitor] = useState( + defaultMonitor + ); + const [qualityProfileId, setQualityProfileId] = useState( + defaultQualityProfileId + ); + const [seriesType, setSeriesType] = useState( + defaultSeriesType + ); + const [seasonFolder, setSeasonFolder] = useState( + defaultSeasonFolder + ); + + const [selectState] = useSelect(); + + const selectedIds = useMemo(() => { + return getSelectedIds(selectState.selectedState, (id) => id); + }, [selectState.selectedState]); + + const { + hasUnsearchedItems, + isMonitorMixed, + isQualityProfileIdMixed, + isSeriesTypeMixed, + isSeasonFolderMixed, + } = useMemo(() => { + let isMonitorMixed = false; + let isQualityProfileIdMixed = false; + let isSeriesTypeMixed = false; + let isSeasonFolderMixed = false; + let hasUnsearchedItems = false; + + items.forEach((item) => { + if (item.monitor !== defaultMonitor) { + isMonitorMixed = true; + } + + if (item.qualityProfileId !== defaultQualityProfileId) { + isQualityProfileIdMixed = true; + } + + if (item.seriesType !== defaultSeriesType) { + isSeriesTypeMixed = true; + } + + if (item.seasonFolder !== defaultSeasonFolder) { + isSeasonFolderMixed = true; + } + + if (!item.isPopulated) { + hasUnsearchedItems = true; + } + }); + + return { + hasUnsearchedItems: !isLookingUpSeries && hasUnsearchedItems, + isMonitorMixed, + isQualityProfileIdMixed, + isSeriesTypeMixed, + isSeasonFolderMixed, + }; + }, [ + defaultMonitor, + defaultQualityProfileId, + defaultSeasonFolder, + defaultSeriesType, + items, + isLookingUpSeries, + ]); + + const handleInputChange = useCallback( + ({ name, value }: InputChanged) => { + if (name === 'monitor') { + setMonitor(value as SeriesMonitor); + } else if (name === 'qualityProfileId') { + setQualityProfileId(value as number); + } else if (name === 'seriesType') { + setSeriesType(value as SeriesType); + } else if (name === 'seasonFolder') { + setSeasonFolder(value as boolean); + } + + dispatch(setAddSeriesDefault({ [name]: value })); + + selectedIds.forEach((id) => { + dispatch( + // @ts-expect-error - actions are not typed + setImportSeriesValue({ + id, + [name]: value, + }) + ); + }); + }, + [selectedIds, dispatch] + ); + + const handleLookupPress = useCallback(() => { + dispatch(lookupUnsearchedSeries()); + }, [dispatch]); + + const handleCancelLookupPress = useCallback(() => { + dispatch(cancelLookupSeries()); + }, [dispatch]); + + const handleImportPress = useCallback(() => { + dispatch(importSeries({ ids: selectedIds })); + }, [selectedIds, dispatch]); + + useEffect(() => { + if (isMonitorMixed && monitor !== 'mixed') { + setMonitor('mixed'); + } else if (!isMonitorMixed && monitor !== defaultMonitor) { + setMonitor(defaultMonitor); + } + }, [defaultMonitor, isMonitorMixed, monitor]); + + useEffect(() => { + if (isQualityProfileIdMixed && qualityProfileId !== 'mixed') { + setQualityProfileId('mixed'); + } else if ( + !isQualityProfileIdMixed && + qualityProfileId !== defaultQualityProfileId + ) { + setQualityProfileId(defaultQualityProfileId); + } + }, [defaultQualityProfileId, isQualityProfileIdMixed, qualityProfileId]); + + useEffect(() => { + if (isSeriesTypeMixed && seriesType !== 'mixed') { + setSeriesType('mixed'); + } else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) { + setSeriesType(defaultSeriesType); + } + }, [defaultSeriesType, isSeriesTypeMixed, seriesType]); + + useEffect(() => { + if (isSeasonFolderMixed && seasonFolder !== 'mixed') { + setSeasonFolder('mixed'); + } else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) { + setSeasonFolder(defaultSeasonFolder); + } + }, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]); + + const selectedCount = selectedIds.length; + + return ( + + + {translate('Monitor')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('SeriesType')} + + + + + + {translate('SeasonFolder')} + + + + + + + + + + {translate('ImportCountSeries', { selectedCount })} + + + {isLookingUpSeries ? ( + + {translate('CancelProcessing')} + + ) : null} + + {hasUnsearchedItems ? ( + + {translate('StartProcessing')} + + ) : null} + + {isLookingUpSeries ? ( + + ) : null} + + {isLookingUpSeries ? translate('ProcessingFolders') : null} + + {importError ? ( + + } + title={translate('ImportErrors')} + body={ + + {Array.isArray(importError.responseJSON) ? ( + importError.responseJSON.map((error, index) => { + return {error.errorMessage}; + }) + ) : ( + {JSON.stringify(importError.responseJSON)} + )} + + } + position={tooltipPositions.RIGHT} + /> + ) : null} + + + + ); +} + +export default ImportSeriesFooter; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js deleted file mode 100644 index 12a90aae7..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js +++ /dev/null @@ -1,63 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { cancelLookupSeries, lookupUnsearchedSeries } from 'Store/Actions/importSeriesActions'; -import ImportSeriesFooter from './ImportSeriesFooter'; - -function isMixed(items, selectedIds, defaultValue, key) { - return _.some(items, (series) => { - return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue; - }); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.addSeries, - (state) => state.importSeries, - (state, { selectedIds }) => selectedIds, - (addSeries, importSeries, selectedIds) => { - const { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - seriesType: defaultSeriesType, - seasonFolder: defaultSeasonFolder - } = addSeries.defaults; - - const { - isLookingUpSeries, - isImporting, - items, - importError - } = importSeries; - - const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); - const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); - const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType'); - const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder'); - const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated); - - return { - selectedCount: selectedIds.length, - isLookingUpSeries, - isImporting, - defaultMonitor, - defaultQualityProfileId, - defaultSeriesType, - defaultSeasonFolder, - isMonitorMixed, - isQualityProfileIdMixed, - isSeriesTypeMixed, - isSeasonFolderMixed, - importError, - hasUnsearchedItems - }; - } - ); -} - -const mapDispatchToProps = { - onLookupPress: lookupUnsearchedSeries, - onCancelLookupPress: cancelLookupSeries -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.tsx similarity index 65% rename from frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js rename to frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.tsx index 6f44b9754..36aea5269 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent'; import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent'; @@ -8,16 +7,21 @@ import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; import Popover from 'Components/Tooltip/Popover'; import { icons, tooltipPositions } from 'Helpers/Props'; +import { CheckInputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; import styles from './ImportSeriesHeader.css'; -function ImportSeriesHeader(props) { - const { - allSelected, - allUnselected, - onSelectAllChange - } = props; +interface ImportSeriesHeaderProps { + allSelected: boolean; + allUnselected: boolean; + onSelectAllChange: (change: CheckInputChanged) => void; +} +function ImportSeriesHeader({ + allSelected, + allUnselected, + onSelectAllChange, +}: ImportSeriesHeaderProps) { return ( - + {translate('Folder')} - + {translate('Monitor')} - } + anchor={} title={translate('MonitoringOptions')} body={} position={tooltipPositions.RIGHT} @@ -59,19 +52,11 @@ function ImportSeriesHeader(props) { {translate('QualityProfile')} - + {translate('SeriesType')} - } + anchor={} title={translate('SeriesType')} body={} position={tooltipPositions.RIGHT} @@ -85,20 +70,11 @@ function ImportSeriesHeader(props) { {translate('SeasonFolder')} - + {translate('Series')} ); } -ImportSeriesHeader.propTypes = { - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - onSelectAllChange: PropTypes.func.isRequired -}; - export default ImportSeriesHeader; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js deleted file mode 100644 index 7777c768f..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js +++ /dev/null @@ -1,105 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import { inputTypes } from 'Helpers/Props'; -import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector'; -import styles from './ImportSeriesRow.css'; - -function ImportSeriesRow(props) { - const { - id, - relativePath, - monitor, - qualityProfileId, - seasonFolder, - seriesType, - selectedSeries, - isExistingSeries, - isSelected, - onSelectedChange, - onInputChange - } = props; - - return ( - <> - - - - {relativePath} - - - - - - - - - - - - - - - - - - - - - - > - ); -} - -ImportSeriesRow.propTypes = { - id: PropTypes.string.isRequired, - relativePath: PropTypes.string.isRequired, - monitor: PropTypes.string.isRequired, - qualityProfileId: PropTypes.number.isRequired, - seriesType: PropTypes.string.isRequired, - seasonFolder: PropTypes.bool.isRequired, - selectedSeries: PropTypes.object, - isExistingSeries: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired, - onInputChange: PropTypes.func.isRequired -}; - -ImportSeriesRow.defaultsProps = { - items: [] -}; - -export default ImportSeriesRow; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx new file mode 100644 index 000000000..508d5a145 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx @@ -0,0 +1,140 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { useSelect } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; +import { ImportSeries } from 'App/State/ImportSeriesAppState'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import { inputTypes } from 'Helpers/Props'; +import { setImportSeriesValue } from 'Store/Actions/importSeriesActions'; +import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector'; +import { InputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries'; +import styles from './ImportSeriesRow.css'; + +function createItemSelector(id: string) { + return createSelector( + (state: AppState) => state.importSeries.items, + (items) => { + return ( + items.find((item) => { + return item.id === id; + }) || ({} as ImportSeries) + ); + } + ); +} + +interface ImportSeriesRowProps { + id: string; +} + +function ImportSeriesRow({ id }: ImportSeriesRowProps) { + const dispatch = useDispatch(); + + const { + relativePath, + monitor, + qualityProfileId, + seasonFolder, + seriesType, + selectedSeries, + } = useSelector(createItemSelector(id)); + + const isExistingSeries = useSelector( + createExistingSeriesSelector(selectedSeries?.tvdbId) + ); + + const [selectState, selectDispatch] = useSelect(); + + const handleInputChange = useCallback( + ({ name, value }: InputChanged) => { + dispatch( + // @ts-expect-error - actions are not typed + setImportSeriesValue({ + id, + [name]: value, + }) + ); + }, + [id, dispatch] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey }: SelectStateInputProps) => { + selectDispatch({ + type: 'toggleSelected', + id, + isSelected: value, + shiftKey, + }); + }, + [selectDispatch] + ); + + console.info( + '\x1b[36m[MarkTest] is selected\x1b[0m', + selectState.selectedState[id] + ); + + return ( + <> + + + + {relativePath} + + + + + + + + + + + + + + + + + + + + + + > + ); +} + +export default ImportSeriesRow; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js deleted file mode 100644 index 3149f9719..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js +++ /dev/null @@ -1,89 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setImportSeriesValue } from 'Store/Actions/importSeriesActions'; -import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; -import ImportSeriesRow from './ImportSeriesRow'; - -function createImportSeriesItemSelector() { - return createSelector( - (state, { id }) => id, - (state) => state.importSeries.items, - (id, items) => { - return _.find(items, { id }) || {}; - } - ); -} - -function createMapStateToProps() { - return createSelector( - createImportSeriesItemSelector(), - createAllSeriesSelector(), - (item, series) => { - const selectedSeries = item && item.selectedSeries; - const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId }); - - return { - ...item, - isExistingSeries - }; - } - ); -} - -const mapDispatchToProps = { - setImportSeriesValue -}; - -class ImportSeriesRowConnector extends Component { - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setImportSeriesValue({ - id: this.props.id, - [name]: value - }); - }; - - // - // Render - - render() { - // Don't show the row until we have the information we require for it. - - const { - items, - monitor, - seriesType, - seasonFolder - } = this.props; - - if (!items || !monitor || !seriesType || !seasonFolder == null) { - return null; - } - - return ( - - ); - } -} - -ImportSeriesRowConnector.propTypes = { - rootFolderId: PropTypes.number.isRequired, - id: PropTypes.string.isRequired, - monitor: PropTypes.string, - seriesType: PropTypes.string, - seasonFolder: PropTypes.bool, - items: PropTypes.arrayOf(PropTypes.object), - setImportSeriesValue: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.css new file mode 100644 index 000000000..df297a5fe --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.css @@ -0,0 +1,7 @@ +.row { + transition: background-color 500ms; + + &:hover { + background-color: var(--tableRowHoverBackgroundColor); + } +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.css.d.ts b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.css.d.ts new file mode 100644 index 000000000..d4b245cd1 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'row': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js deleted file mode 100644 index aa78925f2..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js +++ /dev/null @@ -1,188 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import VirtualTable from 'Components/Table/VirtualTable'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import ImportSeriesHeader from './ImportSeriesHeader'; -import ImportSeriesRowConnector from './ImportSeriesRowConnector'; - -class ImportSeriesTable extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - unmappedFolders, - defaultMonitor, - defaultQualityProfileId, - defaultSeriesType, - defaultSeasonFolder, - onSeriesLookup, - onSetImportSeriesValue - } = this.props; - - const values = { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - seriesType: defaultSeriesType, - seasonFolder: defaultSeasonFolder - }; - - unmappedFolders.forEach((unmappedFolder) => { - const id = unmappedFolder.name; - - onSeriesLookup(id, unmappedFolder.path, unmappedFolder.relativePath); - - onSetImportSeriesValue({ - id, - ...values - }); - }); - } - - // This isn't great, but it's the most reliable way to ensure the items - // are checked off even if they aren't actually visible since the cells - // are virtualized. - - componentDidUpdate(prevProps) { - const { - items, - selectedState, - onSelectedChange, - onRemoveSelectedStateItem - } = this.props; - - prevProps.items.forEach((prevItem) => { - const { - id - } = prevItem; - - const item = _.find(items, { id }); - - if (!item) { - onRemoveSelectedStateItem(id); - return; - } - - const selectedSeries = item.selectedSeries; - const isSelected = selectedState[id]; - - const isExistingSeries = !!selectedSeries && - _.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId }); - - // Props doesn't have a selected series or - // the selected series is an existing series. - if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) { - onSelectedChange({ id, value: false }); - - return; - } - - // State is selected, but a series isn't selected or - // the selected series is an existing series. - if (isSelected && (!selectedSeries || isExistingSeries)) { - onSelectedChange({ id, value: false }); - - return; - } - - // A series is being selected that wasn't previously selected. - if (selectedSeries && selectedSeries !== prevItem.selectedSeries) { - onSelectedChange({ id, value: true }); - - return; - } - }); - } - - // - // Control - - rowRenderer = ({ key, rowIndex, style }) => { - const { - rootFolderId, - items, - selectedState, - onSelectedChange - } = this.props; - - const item = items[rowIndex]; - - return ( - - - - ); - }; - - // - // Render - - render() { - const { - items, - allSelected, - allUnselected, - isSmallScreen, - scroller, - selectedState, - onSelectAllChange - } = this.props; - - if (!items.length) { - return null; - } - - return ( - - } - selectedState={selectedState} - /> - ); - } -} - -ImportSeriesTable.propTypes = { - rootFolderId: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object), - unmappedFolders: PropTypes.arrayOf(PropTypes.object), - defaultMonitor: PropTypes.string.isRequired, - defaultQualityProfileId: PropTypes.number, - defaultSeriesType: PropTypes.string.isRequired, - defaultSeasonFolder: PropTypes.bool.isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - selectedState: PropTypes.object.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - allSeries: PropTypes.arrayOf(PropTypes.object), - scroller: PropTypes.instanceOf(Element).isRequired, - onSelectAllChange: PropTypes.func.isRequired, - onSelectedChange: PropTypes.func.isRequired, - onRemoveSelectedStateItem: PropTypes.func.isRequired, - onSeriesLookup: PropTypes.func.isRequired, - onSetImportSeriesValue: PropTypes.func.isRequired -}; - -export default ImportSeriesTable; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx new file mode 100644 index 000000000..72294b757 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx @@ -0,0 +1,209 @@ +import React, { RefObject, useCallback, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; +import { useSelect } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; +import { ImportSeries } from 'App/State/ImportSeriesAppState'; +import VirtualTable from 'Components/Table/VirtualTable'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { + queueLookupSeries, + setImportSeriesValue, +} from 'Store/Actions/importSeriesActions'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { CheckInputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import { UnmappedFolder } from 'typings/RootFolder'; +import ImportSeriesHeader from './ImportSeriesHeader'; +import ImportSeriesRow from './ImportSeriesRow'; +import styles from './ImportSeriesTable.css'; + +const ROW_HEIGHT = 52; + +interface RowItemData { + items: ImportSeries[]; +} + +interface ImportSeriesTableProps { + unmappedFolders: UnmappedFolder[]; + scrollerRef: RefObject; +} + +function Row({ index, style, data }: ListChildComponentProps) { + const { items } = data; + + if (index >= items.length) { + return null; + } + + const item = items[index]; + + return ( + + + + ); +} + +function ImportSeriesTable({ + unmappedFolders, + scrollerRef, +}: ImportSeriesTableProps) { + const dispatch = useDispatch(); + + const { monitor, qualityProfileId, seriesType, seasonFolder } = useSelector( + (state: AppState) => state.addSeries.defaults + ); + + const items = useSelector((state: AppState) => state.importSeries.items); + const { isSmallScreen } = useSelector(createDimensionsSelector()); + const allSeries = useSelector(createAllSeriesSelector()); + const [selectState, selectDispatch] = useSelect(); + + const defaultValues = useRef({ + monitor, + qualityProfileId, + seriesType, + seasonFolder, + }); + + const listRef = useRef>(null); + const initialUnmappedFolders = useRef(unmappedFolders); + const previousItems = usePrevious(items); + const { allSelected, allUnselected, selectedState } = selectState; + + const handleSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + selectDispatch({ + type: value ? 'selectAll' : 'unselectAll', + }); + }, + [selectDispatch] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey }: SelectStateInputProps) => { + selectDispatch({ + type: 'toggleSelected', + id, + isSelected: value, + shiftKey, + }); + }, + [selectDispatch] + ); + + const handleRemoveSelectedStateItem = useCallback( + (id: string) => { + selectDispatch({ + type: 'removeItem', + id, + }); + }, + [selectDispatch] + ); + + useEffect(() => { + initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => { + dispatch( + queueLookupSeries({ + name, + path, + relativePath, + term: name, + }) + ); + + dispatch( + // @ts-expect-error - actions are not typed + setImportSeriesValue({ + id: name, + ...defaultValues.current, + }) + ); + }); + }, [dispatch]); + + useEffect(() => { + previousItems?.forEach((prevItem) => { + const { id } = prevItem; + + const item = items.find((i) => i.id === id); + + if (!item) { + handleRemoveSelectedStateItem(id); + return; + } + + const selectedSeries = item.selectedSeries; + const isSelected = selectedState[id]; + + const isExistingSeries = + !!selectedSeries && + allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId); + + if ( + (!selectedSeries && prevItem.selectedSeries) || + (isExistingSeries && !prevItem.selectedSeries) + ) { + handleSelectedChange({ id, value: false, shiftKey: false }); + + return; + } + + if (isSelected && (!selectedSeries || isExistingSeries)) { + handleSelectedChange({ id, value: false, shiftKey: false }); + + return; + } + + if (selectedSeries && selectedSeries !== prevItem.selectedSeries) { + handleSelectedChange({ id, value: true, shiftKey: false }); + + return; + } + }); + }, [ + allSeries, + items, + previousItems, + selectedState, + handleRemoveSelectedStateItem, + handleSelectedChange, + ]); + + if (!items.length) { + return null; + } + + return ( + + } + itemCount={items.length} + itemData={{ + items, + }} + isSmallScreen={isSmallScreen} + listRef={listRef} + rowHeight={ROW_HEIGHT} + Row={Row} + scrollerRef={scrollerRef} + /> + ); +} + +export default ImportSeriesTable; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js deleted file mode 100644 index 8a26fbe79..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js +++ /dev/null @@ -1,44 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions'; -import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; -import ImportSeriesTable from './ImportSeriesTable'; - -function createMapStateToProps() { - return createSelector( - (state) => state.addSeries, - (state) => state.importSeries, - (state) => state.app.dimensions, - createAllSeriesSelector(), - (addSeries, importSeries, dimensions, allSeries) => { - return { - defaultMonitor: addSeries.defaults.monitor, - defaultQualityProfileId: addSeries.defaults.qualityProfileId, - defaultSeriesType: addSeries.defaults.seriesType, - defaultSeasonFolder: addSeries.defaults.seasonFolder, - items: importSeries.items, - isSmallScreen: dimensions.isSmallScreen, - allSeries - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onSeriesLookup(name, path, relativePath) { - dispatch(queueLookupSeries({ - name, - path, - relativePath, - term: name - })); - }, - - onSetImportSeriesValue(values) { - dispatch(setImportSeriesValue(values)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.tsx similarity index 56% rename from frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js rename to frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.tsx index 60848ce85..e39ea4fbb 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.tsx @@ -1,29 +1,36 @@ -import PropTypes from 'prop-types'; import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; import { icons } from 'Helpers/Props'; +import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector'; import ImportSeriesTitle from './ImportSeriesTitle'; import styles from './ImportSeriesSearchResult.css'; -function ImportSeriesSearchResult(props) { - const { - tvdbId, - title, - year, - network, - isExistingSeries, - onPress - } = props; +interface ImportSeriesSearchResultProps { + tvdbId: number; + title: string; + year: number; + network?: string; + onPress: (tvdbId: number) => void; +} - const onPressCallback = useCallback(() => onPress(tvdbId), [tvdbId, onPress]); +function ImportSeriesSearchResult({ + tvdbId, + title, + year, + network, + onPress, +}: ImportSeriesSearchResultProps) { + const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId)); + + const handlePress = useCallback(() => { + onPress(tvdbId); + }, [tvdbId, onPress]); return ( - + { - return { - isExistingSeries - }; - } - ); -} - -export default connect(createMapStateToProps)(ImportSeriesSearchResult); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js deleted file mode 100644 index 1257be9b1..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js +++ /dev/null @@ -1,303 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Manager, Popper, Reference } from 'react-popper'; -import FormInputButton from 'Components/Form/FormInputButton'; -import TextInput from 'Components/Form/TextInput'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Portal from 'Components/Portal'; -import { icons, kinds } from 'Helpers/Props'; -import getUniqueElementId from 'Utilities/getUniqueElementId'; -import translate from 'Utilities/String/translate'; -import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector'; -import ImportSeriesTitle from './ImportSeriesTitle'; -import styles from './ImportSeriesSelectSeries.css'; - -class ImportSeriesSelectSeries extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._seriesLookupTimeout = null; - this._scheduleUpdate = null; - this._buttonId = getUniqueElementId(); - this._contentId = getUniqueElementId(); - - this.state = { - term: props.id, - isOpen: false - }; - } - - componentDidUpdate() { - if (this._scheduleUpdate) { - this._scheduleUpdate(); - } - } - - // - // Control - - _addListener() { - window.addEventListener('click', this.onWindowClick); - } - - _removeListener() { - window.removeEventListener('click', this.onWindowClick); - } - - // - // Listeners - - onWindowClick = (event) => { - const button = document.getElementById(this._buttonId); - const content = document.getElementById(this._contentId); - - if (!button || !content) { - return; - } - - if ( - !button.contains(event.target) && - !content.contains(event.target) && - this.state.isOpen - ) { - this.setState({ isOpen: false }); - this._removeListener(); - } - }; - - onPress = () => { - if (this.state.isOpen) { - this._removeListener(); - } else { - this._addListener(); - } - - this.setState({ isOpen: !this.state.isOpen }); - }; - - onSearchInputChange = ({ value }) => { - if (this._seriesLookupTimeout) { - clearTimeout(this._seriesLookupTimeout); - } - - this.setState({ term: value }, () => { - this._seriesLookupTimeout = setTimeout(() => { - this.props.onSearchInputChange(value); - }, 200); - }); - }; - - onRefreshPress = () => { - this.props.onSearchInputChange(this.state.term); - }; - - onSeriesSelect = (tvdbId) => { - this.setState({ isOpen: false }); - - this.props.onSeriesSelect(tvdbId); - }; - - // - // Render - - render() { - const { - selectedSeries, - isExistingSeries, - isFetching, - isPopulated, - error, - items, - isQueued, - isLookingUpSeries - } = this.props; - - const errorMessage = error && - error.responseJSON && - error.responseJSON.message; - - return ( - - - {({ ref }) => ( - - - { - isLookingUpSeries && isQueued && !isPopulated ? - : - null - } - - { - isPopulated && selectedSeries && isExistingSeries ? - : - null - } - - { - isPopulated && selectedSeries ? - : - null - } - - { - isPopulated && !selectedSeries ? - - - - {translate('NoMatchFound')} - : - null - } - - { - !isFetching && !!error ? - - - - {translate('SearchFailedError')} - : - null - } - - - - - - - )} - - - - - {({ ref, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( - - { - this.state.isOpen ? - - - - - - - - - - - - - - - { - items.map((item) => { - return ( - - ); - }) - } - - : - null - } - - ); - }} - - - - ); - } -} - -ImportSeriesSelectSeries.propTypes = { - id: PropTypes.string.isRequired, - selectedSeries: PropTypes.object, - isExistingSeries: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isQueued: PropTypes.bool.isRequired, - isLookingUpSeries: PropTypes.bool.isRequired, - onSearchInputChange: PropTypes.func.isRequired, - onSeriesSelect: PropTypes.func.isRequired -}; - -ImportSeriesSelectSeries.defaultProps = { - isFetching: true, - isPopulated: false, - items: [], - isQueued: true -}; - -export default ImportSeriesSelectSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.tsx b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.tsx new file mode 100644 index 000000000..31dfc58eb --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.tsx @@ -0,0 +1,304 @@ +import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { Manager, Popper, Reference } from 'react-popper'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FormInputButton from 'Components/Form/FormInputButton'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Portal from 'Components/Portal'; +import { icons, kinds } from 'Helpers/Props'; +import { + queueLookupSeries, + setImportSeriesValue, +} from 'Store/Actions/importSeriesActions'; +import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector'; +import { InputChanged } from 'typings/inputs'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ImportSeriesSearchResult from './ImportSeriesSearchResult'; +import ImportSeriesTitle from './ImportSeriesTitle'; +import styles from './ImportSeriesSelectSeries.css'; + +interface ImportSeriesSelectSeriesProps { + id: string; + onInputChange: (input: InputChanged) => void; +} + +function ImportSeriesSelectSeries({ + id, + onInputChange, +}: ImportSeriesSelectSeriesProps) { + const dispatch = useDispatch(); + const isLookingUpSeries = useSelector( + (state: AppState) => state.importSeries.isLookingUpSeries + ); + + const { + error, + isFetching = true, + isPopulated = false, + items = [], + isQueued = true, + selectedSeries, + isExistingSeries, + term: itemTerm, + // @ts-expect-error - ignoring this for now + } = useSelector(createImportSeriesItemSelector(id, { id })); + + const buttonId = useId(); + const contentId = useId(); + const updater = useRef<(() => void) | null>(null); + const seriesLookupTimeout = useRef>(); + + const [term, setTerm] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + const errorMessage = getErrorMessage(error); + + const handleWindowClick = useCallback( + (event: MouseEvent) => { + const button = document.getElementById(buttonId); + const content = document.getElementById(contentId); + const eventTarget = event.target as HTMLElement; + + if (!button || !eventTarget.isConnected) { + return; + } + + if ( + !button.contains(eventTarget) && + content && + !content.contains(eventTarget) && + isOpen + ) { + setIsOpen(false); + window.removeEventListener('click', handleWindowClick); + } + }, + [isOpen, buttonId, contentId, setIsOpen] + ); + + const addListener = useCallback(() => { + window.addEventListener('click', handleWindowClick); + }, [handleWindowClick]); + + const removeListener = useCallback(() => { + window.removeEventListener('click', handleWindowClick); + }, [handleWindowClick]); + + const handlePress = useCallback(() => { + setIsOpen((prevIsOpen) => !prevIsOpen); + }, []); + + const handleSearchInputChange = useCallback( + ({ value }: InputChanged) => { + if (seriesLookupTimeout.current) { + clearTimeout(seriesLookupTimeout.current); + } + + setTerm(value); + + seriesLookupTimeout.current = setTimeout(() => { + dispatch( + queueLookupSeries({ + name: id, + term: value, + topOfQueue: true, + }) + ); + }, 200); + }, + [id, dispatch] + ); + + const handleRefreshPress = useCallback(() => { + dispatch( + queueLookupSeries({ + name: id, + term, + topOfQueue: true, + }) + ); + }, [id, term, dispatch]); + + const handleSeriesSelect = useCallback( + (tvdbId: number) => { + setIsOpen(false); + + const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!; + + dispatch( + // @ts-expect-error - actions are not typed + setImportSeriesValue({ + id, + selectedSeries, + }) + ); + + if (selectedSeries.seriesType !== 'standard') { + onInputChange({ + name: 'seriesType', + value: selectedSeries.seriesType, + }); + } + }, + [id, items, dispatch, onInputChange] + ); + + useEffect(() => { + if (updater.current) { + updater.current(); + } + }); + + useEffect(() => { + if (isOpen) { + addListener(); + } else { + removeListener(); + } + + return removeListener; + }, [isOpen, addListener, removeListener]); + + useEffect(() => { + setTerm(itemTerm); + }, [itemTerm]); + + return ( + + + {({ ref }) => ( + + + {isLookingUpSeries && isQueued && !isPopulated ? ( + + ) : null} + + {isPopulated && selectedSeries && isExistingSeries ? ( + + ) : null} + + {isPopulated && selectedSeries ? ( + + ) : null} + + {isPopulated && !selectedSeries ? ( + + + + {translate('NoMatchFound')} + + ) : null} + + {!isFetching && !!error ? ( + + + + {translate('SearchFailedError')} + + ) : null} + + + + + + + )} + + + + + {({ ref, style, scheduleUpdate }) => { + updater.current = scheduleUpdate; + + return ( + + {isOpen ? ( + + + + + + + + + + + + + + + {items.map((item) => { + return ( + + ); + })} + + + ) : null} + + ); + }} + + + + ); +} + +export default ImportSeriesSelectSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js deleted file mode 100644 index 935c95f4e..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js +++ /dev/null @@ -1,87 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions'; -import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector'; -import * as seriesTypes from 'Utilities/Series/seriesTypes'; -import ImportSeriesSelectSeries from './ImportSeriesSelectSeries'; - -function createMapStateToProps() { - return createSelector( - (state) => state.importSeries.isLookingUpSeries, - createImportSeriesItemSelector(), - (isLookingUpSeries, item) => { - return { - isLookingUpSeries, - ...item - }; - } - ); -} - -const mapDispatchToProps = { - queueLookupSeries, - setImportSeriesValue -}; - -class ImportSeriesSelectSeriesConnector extends Component { - - // - // Listeners - - onSearchInputChange = (term) => { - this.props.queueLookupSeries({ - name: this.props.id, - term, - topOfQueue: true - }); - }; - - onSeriesSelect = (tvdbId) => { - const { - id, - items, - onInputChange - } = this.props; - - const selectedSeries = items.find((item) => item.tvdbId === tvdbId); - - this.props.setImportSeriesValue({ - id, - selectedSeries - }); - - if (selectedSeries.seriesType !== seriesTypes.STANDARD) { - onInputChange({ - name: 'seriesType', - value: selectedSeries.seriesType - }); - } - }; - - // - // Render - - render() { - return ( - - ); - } -} - -ImportSeriesSelectSeriesConnector.propTypes = { - id: PropTypes.string.isRequired, - items: PropTypes.arrayOf(PropTypes.object), - selectedSeries: PropTypes.object, - isSelected: PropTypes.bool, - onInputChange: PropTypes.func.isRequired, - queueLookupSeries: PropTypes.func.isRequired, - setImportSeriesValue: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js deleted file mode 100644 index c7ea7b961..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js +++ /dev/null @@ -1,57 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './ImportSeriesTitle.css'; - -function ImportSeriesTitle(props) { - const { - title, - year, - network, - isExistingSeries - } = props; - - return ( - - - {title} - - - { - !title.contains(year) && - year > 0 ? - - ({year}) - : - null - } - - { - network ? - {network} : - null - } - - { - isExistingSeries ? - - {translate('Existing')} - : - null - } - - ); -} - -ImportSeriesTitle.propTypes = { - title: PropTypes.string.isRequired, - year: PropTypes.number.isRequired, - network: PropTypes.string, - isExistingSeries: PropTypes.bool.isRequired -}; - -export default ImportSeriesTitle; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.tsx b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.tsx new file mode 100644 index 000000000..cf6c9764b --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import Label from 'Components/Label'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ImportSeriesTitle.css'; + +interface ImportSeriesTitleProps { + title: string; + year: number; + network?: string; + isExistingSeries: boolean; +} + +function ImportSeriesTitle({ + title, + year, + network, + isExistingSeries, +}: ImportSeriesTitleProps) { + return ( + + {title} + + {year > 0 && !title.includes(String(year)) ? ( + ({year}) + ) : null} + + {network ? {network} : null} + + {isExistingSeries ? ( + {translate('Existing')} + ) : null} + + ); +} + +export default ImportSeriesTitle; diff --git a/frontend/src/AddSeries/ImportSeries/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/ImportSeries.js deleted file mode 100644 index 8f9fed325..000000000 --- a/frontend/src/AddSeries/ImportSeries/ImportSeries.js +++ /dev/null @@ -1,30 +0,0 @@ -import React, { Component } from 'react'; -import { Route } from 'react-router-dom'; -import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector'; -import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector'; -import Switch from 'Components/Router/Switch'; - -class ImportSeries extends Component { - - // - // Render - - render() { - return ( - - - - - - ); - } -} - -export default ImportSeries; diff --git a/frontend/src/AddSeries/ImportSeries/ImportSeriesPage.tsx b/frontend/src/AddSeries/ImportSeries/ImportSeriesPage.tsx new file mode 100644 index 000000000..3d5e16f29 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/ImportSeriesPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import ImportSeries from './Import/ImportSeries'; +import ImportSeriesSelectFolder from './SelectFolder/ImportSeriesSelectFolder'; + +function ImportSeriesPage() { + return ( + + + + + + ); +} + +export default ImportSeriesPage; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js deleted file mode 100644 index 24fcff3dc..000000000 --- a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js +++ /dev/null @@ -1,188 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import FieldSet from 'Components/FieldSet'; -import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import { icons, kinds, sizes } from 'Helpers/Props'; -import RootFolders from 'RootFolder/RootFolders'; -import translate from 'Utilities/String/translate'; -import styles from './ImportSeriesSelectFolder.css'; - -class ImportSeriesSelectFolder extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isAddNewRootFolderModalOpen: false - }; - } - - // - // Lifecycle - - onAddNewRootFolderPress = () => { - this.setState({ isAddNewRootFolderModalOpen: true }); - }; - - onNewRootFolderSelect = ({ value }) => { - this.props.onNewRootFolderSelect(value); - }; - - onAddRootFolderModalClose = () => { - this.setState({ isAddNewRootFolderModalOpen: false }); - }; - - // - // Render - - render() { - const { - isWindows, - isFetching, - isPopulated, - isSaving, - error, - saveError, - items - } = this.props; - - const hasRootFolders = items.length > 0; - const goodFolderExample = (isWindows) ? 'C:\\tv shows' : '/tv shows'; - const badFolderExample = (isWindows) ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'; - - return ( - - - { - isFetching && !isPopulated ? - : - null - } - - { - !isFetching && error ? - {translate('RootFoldersLoadError')} : - null - } - - { - !error && isPopulated && - - - {translate('LibraryImportSeriesHeader')} - - - - {translate('LibraryImportTips')} - - - - - - - - - {translate('LibraryImportTipsDontUseDownloadsFolder')} - - - - - { - hasRootFolders ? - - - - - : - null - } - - { - !isSaving && saveError ? - - {translate('AddRootFolderError')} - - - { - Array.isArray(saveError.responseJSON) ? - saveError.responseJSON.map((e, index) => { - return ( - - {e.errorMessage} - - ); - }) : - - { - JSON.stringify(saveError.responseJSON) - } - - } - - : - null - } - - - - - { - hasRootFolders ? - translate('ChooseAnotherFolder') : - translate('StartImport') - } - - - - - - } - - - ); - } -} - -ImportSeriesSelectFolder.propTypes = { - isWindows: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - error: PropTypes.object, - saveError: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onNewRootFolderSelect: PropTypes.func.isRequired -}; - -export default ImportSeriesSelectFolder; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.tsx b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.tsx new file mode 100644 index 000000000..12af35d9a --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +import FieldSet from 'Components/FieldSet'; +import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import RootFolders from 'RootFolder/RootFolders'; +import { + addRootFolder, + fetchRootFolders, +} from 'Store/Actions/rootFolderActions'; +import useIsWindows from 'System/useIsWindows'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import styles from './ImportSeriesSelectFolder.css'; + +function ImportSeriesSelectFolder() { + const dispatch = useDispatch(); + const { isFetching, isPopulated, isSaving, error, saveError, items } = + useSelector((state: AppState) => state.rootFolders); + + const isWindows = useIsWindows(); + + const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] = + useState(false); + + const wasSaving = usePrevious(isSaving); + + const hasRootFolders = items.length > 0; + const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows'; + const badFolderExample = isWindows + ? 'C:\\tv shows\\the simpsons' + : '/tv shows/the simpsons'; + + const handleAddNewRootFolderPress = useCallback(() => { + setIsAddNewRootFolderModalOpen(true); + }, []); + + const handleAddRootFolderModalClose = useCallback(() => { + setIsAddNewRootFolderModalOpen(false); + }, []); + + const handleNewRootFolderSelect = useCallback( + ({ value }: InputChanged) => { + dispatch(addRootFolder({ path: value })); + }, + [dispatch] + ); + + useEffect(() => { + dispatch(fetchRootFolders()); + }, [dispatch]); + + useEffect(() => { + if (!isSaving && wasSaving && !saveError) { + items.reduce((acc, item) => { + if (item.id > acc) { + return item.id; + } + + return acc; + }, 0); + } + }, [isSaving, wasSaving, saveError, items]); + + return ( + + + {isFetching && !isPopulated ? : null} + + {!isFetching && error ? ( + {translate('RootFoldersLoadError')} + ) : null} + + {!error && isPopulated && ( + + + {translate('LibraryImportSeriesHeader')} + + + + {translate('LibraryImportTips')} + + + + + + + + + {translate('LibraryImportTipsDontUseDownloadsFolder')} + + + + + {hasRootFolders ? ( + + + + + + ) : null} + + {!isSaving && saveError ? ( + + {translate('AddRootFolderError')} + + + {Array.isArray(saveError.responseJSON) ? ( + saveError.responseJSON.map((e, index) => { + return {e.errorMessage}; + }) + ) : ( + {JSON.stringify(saveError.responseJSON)} + )} + + + ) : null} + + + + + {hasRootFolders + ? translate('ChooseAnotherFolder') + : translate('StartImport')} + + + + + + )} + + + ); +} + +export default ImportSeriesSelectFolder; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js deleted file mode 100644 index 1df231f4e..000000000 --- a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js +++ /dev/null @@ -1,85 +0,0 @@ -import { push } from 'connected-react-router'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import ImportSeriesSelectFolder from './ImportSeriesSelectFolder'; - -function createMapStateToProps() { - return createSelector( - createRootFoldersSelector(), - createSystemStatusSelector(), - (rootFolders, systemStatus) => { - return { - ...rootFolders, - isWindows: systemStatus.isWindows - }; - } - ); -} - -const mapDispatchToProps = { - fetchRootFolders, - addRootFolder, - push -}; - -class ImportSeriesSelectFolderConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchRootFolders(); - } - - componentDidUpdate(prevProps) { - const { - items, - isSaving, - saveError - } = this.props; - - if (prevProps.isSaving && !isSaving && !saveError) { - const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id); - - if (newRootFolders.length === 1) { - this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`); - } - } - } - - // - // Listeners - - onNewRootFolderSelect = (path) => { - this.props.addRootFolder({ path }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -ImportSeriesSelectFolderConnector.propTypes = { - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchRootFolders: PropTypes.func.isRequired, - addRootFolder: PropTypes.func.isRequired, - push: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector); diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index ba613de38..364063ac1 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -4,7 +4,7 @@ import Blocklist from 'Activity/Blocklist/Blocklist'; import History from 'Activity/History/History'; import Queue from 'Activity/Queue/Queue'; import AddNewSeries from 'AddSeries/AddNewSeries/AddNewSeries'; -import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; +import ImportSeriesPage from 'AddSeries/ImportSeries/ImportSeriesPage'; import CalendarPage from 'Calendar/CalendarPage'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; @@ -60,7 +60,7 @@ function AppRoutes() { - + diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx index eca22c6c7..ee0de5e4a 100644 --- a/frontend/src/App/SelectContext.tsx +++ b/frontend/src/App/SelectContext.tsx @@ -1,6 +1,9 @@ import { cloneDeep } from 'lodash'; import React, { useCallback, useEffect } from 'react'; -import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState'; +import useSelectState, { + SelectState, + SelectStateModel, +} from 'Helpers/Hooks/useSelectState'; import ModelBase from './ModelBase'; export type SelectContextAction = @@ -24,7 +27,7 @@ export type SelectContextAction = export type SelectDispatch = (action: SelectContextAction) => void; -interface SelectProviderOptions { +interface SelectProviderOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any children: any; items: Array; @@ -34,7 +37,7 @@ const SelectContext = React.createContext< [SelectState, SelectDispatch] | undefined >(cloneDeep(undefined)); -export function SelectProvider( +export function SelectProvider( props: SelectProviderOptions ) { const { items } = props; diff --git a/frontend/src/App/State/AddSeriesAppState.ts b/frontend/src/App/State/AddSeriesAppState.ts index 54a8c260a..4bf5ba7cd 100644 --- a/frontend/src/App/State/AddSeriesAppState.ts +++ b/frontend/src/App/State/AddSeriesAppState.ts @@ -1,5 +1,4 @@ import AppSectionState, { Error } from 'App/State/AppSectionState'; -import Language from 'Language/Language'; import Series, { SeriesMonitor, SeriesType } from 'Series/Series'; export interface AddSeries extends Series { @@ -17,7 +16,6 @@ interface AddSeriesAppState extends AppSectionState { qualityProfileId: number; seriesType: SeriesType; seasonFolder: boolean; - language: Language; tags: number[]; searchForMissingEpisodes: boolean; searchForCutoffUnmetEpisodes: boolean; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index ce60b6b20..da9996632 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -11,6 +11,7 @@ import CustomFiltersAppState from './CustomFiltersAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodesAppState from './EpisodesAppState'; import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState'; +import ImportSeriesAppState from './ImportSeriesAppState'; import InteractiveImportAppState from './InteractiveImportAppState'; import MessagesAppState from './MessagesAppState'; import OAuthAppState from './OAuthAppState'; @@ -94,6 +95,7 @@ interface AppState { episodes: EpisodesAppState; episodesSelection: EpisodesAppState; history: HistoryAppState; + importSeries: ImportSeriesAppState; interactiveImport: InteractiveImportAppState; oAuth: OAuthAppState; organizePreview: OrganizePreviewAppState; diff --git a/frontend/src/App/State/ImportSeriesAppState.ts b/frontend/src/App/State/ImportSeriesAppState.ts new file mode 100644 index 000000000..f4bf4b56a --- /dev/null +++ b/frontend/src/App/State/ImportSeriesAppState.ts @@ -0,0 +1,29 @@ +import Series, { SeriesMonitor, SeriesType } from 'Series/Series'; +import { Error } from './AppSectionState'; + +export interface ImportSeries { + id: string; + error?: Error; + isFetching: boolean; + isPopulated: boolean; + isQueued: boolean; + items: Series[]; + monitor: SeriesMonitor; + path: string; + qualityProfileId: number; + relativePath: string; + seasonFolder: boolean; + selectedSeries?: Series; + seriesType: SeriesType; + term: string; +} + +interface ImportSeriesAppState { + isLookingUpSeries: false; + isImporting: false; + isImported: false; + importError: Error | null; + items: ImportSeries[]; +} + +export default ImportSeriesAppState; diff --git a/frontend/src/Components/Form/FormInputButton.tsx b/frontend/src/Components/Form/FormInputButton.tsx index 1235010eb..fbe9b4670 100644 --- a/frontend/src/Components/Form/FormInputButton.tsx +++ b/frontend/src/Components/Form/FormInputButton.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; import React from 'react'; +import { IconName } from 'Components/Icon'; import Button, { ButtonProps } from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; import { kinds } from 'Helpers/Props'; @@ -9,6 +10,7 @@ export interface FormInputButtonProps extends ButtonProps { canSpin?: boolean; isLastButton?: boolean; isSpinning?: boolean; + spinnerIcon?: IconName; } function FormInputButton({ diff --git a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx index fab6d9ee9..08142195f 100644 --- a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx +++ b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx @@ -19,6 +19,7 @@ export interface LanguageSelectInputProps { includeNoChange?: boolean; includeNoChangeDisabled?: boolean; includeMixed?: boolean; + isDisabled?: boolean; onChange: (payload: LanguageSelectInputOnChangeProps) => void; } diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.tsx b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.tsx index 73cb7e523..924ed08ad 100644 --- a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.tsx +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.tsx @@ -9,7 +9,7 @@ import styles from './VirtualTableSelectCell.css'; interface VirtualTableSelectCellProps extends VirtualTableRowCellProps { inputClassName?: string; - id: number; + id: number | string; isSelected?: boolean; isDisabled: boolean; onSelectedChange: (options: SelectStateInputProps) => void; diff --git a/frontend/src/Components/Table/VirtualTable.css b/frontend/src/Components/Table/VirtualTable.css index 81111bcf9..455f0bc7c 100644 --- a/frontend/src/Components/Table/VirtualTable.css +++ b/frontend/src/Components/Table/VirtualTable.css @@ -1,7 +1,3 @@ -.tableContainer { - width: 100%; -} - -.tableBodyContainer { +.tableScroller { position: relative; } diff --git a/frontend/src/Components/Table/VirtualTable.css.d.ts b/frontend/src/Components/Table/VirtualTable.css.d.ts index 26af27e05..712cb8f72 100644 --- a/frontend/src/Components/Table/VirtualTable.css.d.ts +++ b/frontend/src/Components/Table/VirtualTable.css.d.ts @@ -1,8 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'tableBodyContainer': string; - 'tableContainer': string; + 'tableScroller': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Components/Table/VirtualTable.tsx b/frontend/src/Components/Table/VirtualTable.tsx index 362be113b..3f68626c1 100644 --- a/frontend/src/Components/Table/VirtualTable.tsx +++ b/frontend/src/Components/Table/VirtualTable.tsx @@ -1,166 +1,121 @@ -import React, { ReactNode, useEffect, useRef } from 'react'; -import { Grid, GridCellProps, WindowScroller } from 'react-virtualized'; -import ModelBase from 'App/ModelBase'; +import { throttle } from 'lodash'; +import React, { RefObject, useEffect, useState } from 'react'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; import Scroller from 'Components/Scroller/Scroller'; import useMeasure from 'Helpers/Hooks/useMeasure'; -import usePrevious from 'Helpers/Hooks/usePrevious'; -import { scrollDirections } from 'Helpers/Props'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import dimensions from 'Styles/Variables/dimensions'; import styles from './VirtualTable.css'; -const ROW_HEIGHT = 38; +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); -function overscanIndicesGetter(options: { - cellCount: number; - overscanCellsCount: number; - startIndex: number; - stopIndex: number; -}) { - const { cellCount, overscanCellsCount, startIndex, stopIndex } = options; - - // The default getter takes the scroll direction into account, - // but that can cause issues. Ignore the scroll direction and - // always over return more items. - - const overscanStartIndex = startIndex - overscanCellsCount; - const overscanStopIndex = stopIndex + overscanCellsCount; - - return { - overscanStartIndex: Math.max(0, overscanStartIndex), - overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex), - }; -} - -interface VirtualTableProps { +interface VirtualTableProps { + Header: React.JSX.Element; + itemCount: number; + itemData: T; isSmallScreen: boolean; - className?: string; - items: T[]; - scrollIndex?: number; - scrollTop?: number; - scroller: Element; - header: React.ReactNode; - headerHeight?: number; - rowRenderer: (rowProps: GridCellProps) => ReactNode; - rowHeight?: number; + listRef: RefObject>; + rowHeight: number; + Row({ + index, + style, + data, + }: ListChildComponentProps): React.JSX.Element | null; + scrollerRef: RefObject; } -function VirtualTable({ +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function VirtualTable({ + Header, + itemCount, + itemData, isSmallScreen, - className = styles.tableContainer, - items, - scroller, - scrollIndex, - scrollTop, - header, - headerHeight = 38, - rowHeight = ROW_HEIGHT, - rowRenderer, - ...otherProps + listRef, + rowHeight, + Row, + scrollerRef, }: VirtualTableProps) { const [measureRef, bounds] = useMeasure(); - const gridRef = useRef(null); - const scrollRestored = useRef(false); - const previousScrollIndex = usePrevious(scrollIndex); - const previousItems = usePrevious(items); - - const width = bounds.width; - - const gridStyle = { - boxSizing: undefined, - direction: undefined, - height: undefined, - position: undefined, - willChange: undefined, - overflow: undefined, - width: undefined, - }; - - const containerStyle = { - position: undefined, - }; + const [size, setSize] = useState({ width: 0, height: 0 }); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; useEffect(() => { - if (gridRef.current && width > 0) { - gridRef.current.recomputeGridSize(); + const current = scrollerRef?.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: windowWidth, + height: windowHeight, + }); + + return; } - }, [width]); - useEffect(() => { - if ( - gridRef.current && - previousItems && - hasDifferentItemsOrOrder(previousItems, items) - ) { - gridRef.current.recomputeGridSize(); - } - }, [items, previousItems]); + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; - useEffect(() => { - if (gridRef.current && scrollTop && !scrollRestored.current) { - gridRef.current.scrollToPosition({ scrollLeft: 0, scrollTop }); - scrollRestored.current = true; - } - }, [scrollTop]); - - useEffect(() => { - if ( - gridRef.current && - scrollIndex != null && - scrollIndex !== previousScrollIndex - ) { - gridRef.current.scrollToCell({ - rowIndex: scrollIndex, - columnIndex: 0, + setSize({ + width: width - padding * 2, + height: windowHeight, }); } - }, [scrollIndex, previousScrollIndex]); + }, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current?.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); return ( - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return null; - } - return ( - - - {header} - - {/* @ts-expect-error - ref type is incompatible */} - - - - - - ); - }} - + + + {Header} + + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={itemCount} + itemSize={rowHeight} + itemData={itemData} + overscanCount={20} + > + {Row} + + + ); } diff --git a/frontend/src/Components/Table/VirtualTableRow.tsx b/frontend/src/Components/Table/VirtualTableRow.tsx index dcdb3da4f..d175ecd1f 100644 --- a/frontend/src/Components/Table/VirtualTableRow.tsx +++ b/frontend/src/Components/Table/VirtualTableRow.tsx @@ -2,7 +2,7 @@ import React from 'react'; import styles from './VirtualTableRow.css'; interface VirtualTableRowProps extends React.HTMLAttributes { - className: string; + className?: string; style: object; children?: React.ReactNode; } diff --git a/frontend/src/Helpers/Hooks/useSelectState.tsx b/frontend/src/Helpers/Hooks/useSelectState.tsx index 4e1038bb6..9593a7e04 100644 --- a/frontend/src/Helpers/Hooks/useSelectState.tsx +++ b/frontend/src/Helpers/Hooks/useSelectState.tsx @@ -1,12 +1,15 @@ import { cloneDeep } from 'lodash'; import { useReducer } from 'react'; -import ModelBase from 'App/ModelBase'; import areAllSelected from 'Utilities/Table/areAllSelected'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; export type SelectedState = Record; +export interface SelectStateModel { + id: number | string; +} + export interface SelectState { selectedState: SelectedState; lastToggled: number | string | null; @@ -16,14 +19,14 @@ export interface SelectState { export type SelectAction = | { type: 'reset' } - | { type: 'selectAll'; items: ModelBase[] } - | { type: 'unselectAll'; items: ModelBase[] } + | { type: 'selectAll'; items: SelectStateModel[] } + | { type: 'unselectAll'; items: SelectStateModel[] } | { type: 'toggleSelected'; id: number | string; isSelected: boolean | null; shiftKey: boolean; - items: ModelBase[]; + items: SelectStateModel[]; } | { type: 'removeItem'; @@ -31,7 +34,7 @@ export type SelectAction = } | { type: 'updateItems'; - items: ModelBase[]; + items: SelectStateModel[]; }; export type Dispatch = (action: SelectAction) => void; @@ -44,7 +47,10 @@ const initialState = { items: [], }; -function getSelectedState(items: ModelBase[], existingState: SelectedState) { +function getSelectedState( + items: SelectStateModel[], + existingState: SelectedState +) { return items.reduce((acc: SelectedState, item) => { const id = item.id; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.css b/frontend/src/Series/Index/Table/SeriesIndexTable.css index 0bfc5fec4..df297a5fe 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTable.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTable.css @@ -1,7 +1,3 @@ -.tableScroller { - position: relative; -} - .row { transition: background-color 500ms; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexTable.css.d.ts index ff35c263f..d4b245cd1 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTable.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTable.css.d.ts @@ -2,7 +2,6 @@ // Please do not change this file! interface CssExports { 'row': string; - 'tableScroller': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.tsx b/frontend/src/Series/Index/Table/SeriesIndexTable.tsx index 1c6064e5c..d0775c8e0 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTable.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexTable.tsx @@ -1,26 +1,18 @@ -import { throttle } from 'lodash'; -import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'; +import React, { RefObject, useEffect, useMemo, useRef } from 'react'; import { useSelector } from 'react-redux'; -import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; -import Scroller from 'Components/Scroller/Scroller'; import Column from 'Components/Table/Column'; -import useMeasure from 'Helpers/Hooks/useMeasure'; +import VirtualTable from 'Components/Table/VirtualTable'; import { SortDirection } from 'Helpers/Props/sortDirections'; import Series from 'Series/Series'; -import dimensions from 'Styles/Variables/dimensions'; import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import selectTableOptions from './selectTableOptions'; import SeriesIndexRow from './SeriesIndexRow'; import SeriesIndexTableHeader from './SeriesIndexTableHeader'; import styles from './SeriesIndexTable.css'; -const bodyPadding = parseInt(dimensions.pageContentBodyPadding); -const bodyPaddingSmallScreen = parseInt( - dimensions.pageContentBodyPaddingSmallScreen -); - interface RowItemData { items: Series[]; sortKey: string; @@ -72,10 +64,6 @@ function Row({ index, style, data }: ListChildComponentProps) { ); } -function getWindowScrollTopPosition() { - return document.documentElement.scrollTop || document.body.scrollTop || 0; -} - function SeriesIndexTable(props: SeriesIndexTableProps) { const { items, @@ -89,65 +77,12 @@ function SeriesIndexTable(props: SeriesIndexTableProps) { const columns = useSelector(columnsSelector); const { showBanners } = useSelector(selectTableOptions); - const listRef = useRef>(null); - const [measureRef, bounds] = useMeasure(); - const [size, setSize] = useState({ width: 0, height: 0 }); - const windowWidth = window.innerWidth; - const windowHeight = window.innerHeight; + const listRef = useRef>(null); const rowHeight = useMemo(() => { return showBanners ? 70 : 38; }, [showBanners]); - useEffect(() => { - const current = scrollerRef?.current as HTMLElement; - - if (isSmallScreen) { - setSize({ - width: windowWidth, - height: windowHeight, - }); - - return; - } - - if (current) { - const width = current.clientWidth; - const padding = - (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; - - setSize({ - width: width - padding * 2, - height: windowHeight, - }); - } - }, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]); - - useEffect(() => { - const currentScrollerRef = scrollerRef.current as HTMLElement; - const currentScrollListener = isSmallScreen ? window : currentScrollerRef; - - const handleScroll = throttle(() => { - const { offsetTop = 0 } = currentScrollerRef; - const scrollTop = - (isSmallScreen - ? getWindowScrollTopPosition() - : currentScrollerRef.scrollTop) - offsetTop; - - listRef.current?.scrollTo(scrollTop); - }, 10); - - currentScrollListener.addEventListener('scroll', handleScroll); - - return () => { - handleScroll.cancel(); - - if (currentScrollListener) { - currentScrollListener.removeEventListener('scroll', handleScroll); - } - }; - }, [isSmallScreen, listRef, scrollerRef]); - useEffect(() => { if (jumpToCharacter) { const index = getIndexOfFirstCharacter(items, jumpToCharacter); @@ -170,8 +105,8 @@ function SeriesIndexTable(props: SeriesIndexTableProps) { }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); return ( - - + - - ref={listRef} - style={{ - width: '100%', - height: '100%', - overflow: 'none', - }} - width={size.width} - height={size.height} - itemCount={items.length} - itemSize={rowHeight} - itemData={{ - items, - sortKey, - columns, - isSelectMode, - }} - > - {Row} - - - + } + itemCount={items.length} + itemData={{ + items, + sortKey, + columns, + isSelectMode, + }} + isSmallScreen={isSmallScreen} + listRef={listRef} + rowHeight={rowHeight} + Row={Row} + scrollerRef={scrollerRef} + /> ); } diff --git a/frontend/src/Store/Selectors/createExistingSeriesSelector.ts b/frontend/src/Store/Selectors/createExistingSeriesSelector.ts index d9080c75b..d9d59503a 100644 --- a/frontend/src/Store/Selectors/createExistingSeriesSelector.ts +++ b/frontend/src/Store/Selectors/createExistingSeriesSelector.ts @@ -1,10 +1,13 @@ -import { some } from 'lodash'; import { createSelector } from 'reselect'; import createAllSeriesSelector from './createAllSeriesSelector'; -function createExistingSeriesSelector(tvdbId: number) { +function createExistingSeriesSelector(tvdbId: number | undefined) { return createSelector(createAllSeriesSelector(), (series) => { - return some(series, { tvdbId }); + if (tvdbId == null) { + return false; + } + + return series.some((s) => s.tvdbId === tvdbId); }); } diff --git a/frontend/src/Store/Selectors/createImportSeriesItemSelector.js b/frontend/src/Store/Selectors/createImportSeriesItemSelector.js deleted file mode 100644 index dc6c28a05..000000000 --- a/frontend/src/Store/Selectors/createImportSeriesItemSelector.js +++ /dev/null @@ -1,28 +0,0 @@ -import _ from 'lodash'; -import { createSelector } from 'reselect'; -import createAllSeriesSelector from './createAllSeriesSelector'; - -function createImportSeriesItemSelector() { - return createSelector( - (state, { id }) => id, - (state) => state.addSeries, - (state) => state.importSeries, - createAllSeriesSelector(), - (id, addSeries, importSeries, series) => { - const item = _.find(importSeries.items, { id }) || {}; - const selectedSeries = item && item.selectedSeries; - const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId }); - - return { - defaultMonitor: addSeries.defaults.monitor, - defaultQualityProfileId: addSeries.defaults.qualityProfileId, - defaultSeriesType: addSeries.defaults.seriesType, - defaultSeasonFolder: addSeries.defaults.seasonFolder, - ...item, - isExistingSeries - }; - } - ); -} - -export default createImportSeriesItemSelector; diff --git a/frontend/src/Store/Selectors/createImportSeriesItemSelector.ts b/frontend/src/Store/Selectors/createImportSeriesItemSelector.ts new file mode 100644 index 000000000..d64cae35e --- /dev/null +++ b/frontend/src/Store/Selectors/createImportSeriesItemSelector.ts @@ -0,0 +1,40 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { ImportSeries } from 'App/State/ImportSeriesAppState'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createImportSeriesItemSelector(id: string) { + return createSelector( + (_state: AppState, connectorInput: { id: string }) => + connectorInput ? connectorInput.id : id, + (state: AppState) => state.addSeries, + (state: AppState) => state.importSeries, + createAllSeriesSelector(), + (connectorId, addSeries, importSeries, series) => { + const finalId = id || connectorId; + + const item = + importSeries.items.find((item) => { + return item.id === finalId; + }) ?? ({} as ImportSeries); + + const selectedSeries = item && item.selectedSeries; + const isExistingSeries = + !!selectedSeries && + series.some((s) => { + return s.tvdbId === selectedSeries.tvdbId; + }); + + return { + defaultMonitor: addSeries.defaults.monitor, + defaultQualityProfileId: addSeries.defaults.qualityProfileId, + defaultSeriesType: addSeries.defaults.seriesType, + defaultSeasonFolder: addSeries.defaults.seasonFolder, + ...item, + isExistingSeries, + }; + } + ); +} + +export default createImportSeriesItemSelector; diff --git a/frontend/src/Utilities/Object/getErrorMessage.ts b/frontend/src/Utilities/Object/getErrorMessage.ts index 17abeccd2..f23a74795 100644 --- a/frontend/src/Utilities/Object/getErrorMessage.ts +++ b/frontend/src/Utilities/Object/getErrorMessage.ts @@ -1,7 +1,10 @@ import { Error } from 'App/State/AppSectionState'; import { ApiError } from 'Helpers/Hooks/useApiQuery'; -function getErrorMessage(error: Error | ApiError, fallbackErrorMessage = '') { +function getErrorMessage( + error: Error | ApiError | undefined, + fallbackErrorMessage = '' +) { if (!error) { return fallbackErrorMessage; } diff --git a/frontend/src/Utilities/Table/getSelectedIds.ts b/frontend/src/Utilities/Table/getSelectedIds.ts index b84db6245..7c1ea7c3b 100644 --- a/frontend/src/Utilities/Table/getSelectedIds.ts +++ b/frontend/src/Utilities/Table/getSelectedIds.ts @@ -1,12 +1,15 @@ import { reduce } from 'lodash'; import { SelectedState } from 'Helpers/Hooks/useSelectState'; -function getSelectedIds(selectedState: SelectedState): number[] { +function getSelectedIds( + selectedState: SelectedState, + idParser: (id: string) => T = (id) => parseInt(id) as T +): T[] { return reduce( selectedState, - (result: number[], value, id) => { + (result: T[], value, id) => { if (value) { - result.push(parseInt(id)); + result.push(idParser(id)); } return result; diff --git a/frontend/src/Utilities/Table/getToggledRange.ts b/frontend/src/Utilities/Table/getToggledRange.ts index 34c2648c9..7888a708b 100644 --- a/frontend/src/Utilities/Table/getToggledRange.ts +++ b/frontend/src/Utilities/Table/getToggledRange.ts @@ -1,6 +1,6 @@ -import ModelBase from 'App/ModelBase'; +import { SelectStateModel } from 'Helpers/Hooks/useSelectState'; -function getToggledRange( +function getToggledRange( items: T[], id: number | string, lastToggled: number | string diff --git a/frontend/src/Utilities/Table/selectAll.ts b/frontend/src/Utilities/Table/selectAll.ts index bc7f8de8c..8c60a3d37 100644 --- a/frontend/src/Utilities/Table/selectAll.ts +++ b/frontend/src/Utilities/Table/selectAll.ts @@ -2,9 +2,9 @@ import { SelectedState } from 'Helpers/Hooks/useSelectState'; function selectAll(selectedState: SelectedState, selected: boolean) { const newSelectedState = Object.keys(selectedState).reduce< - Record + Record >((acc, item) => { - acc[Number(item)] = selected; + acc[item] = selected; return acc; }, {}); diff --git a/frontend/src/Utilities/Table/toggleSelected.ts b/frontend/src/Utilities/Table/toggleSelected.ts index df51ddb25..da75ee498 100644 --- a/frontend/src/Utilities/Table/toggleSelected.ts +++ b/frontend/src/Utilities/Table/toggleSelected.ts @@ -1,9 +1,8 @@ -import ModelBase from 'App/ModelBase'; -import { SelectState } from 'Helpers/Hooks/useSelectState'; +import { SelectState, SelectStateModel } from 'Helpers/Hooks/useSelectState'; import areAllSelected from './areAllSelected'; import getToggledRange from './getToggledRange'; -function toggleSelected( +function toggleSelected( selectState: SelectState, items: T[], id: number | string, diff --git a/frontend/src/typings/RootFolder.ts b/frontend/src/typings/RootFolder.ts index 8d45263e0..4424a4550 100644 --- a/frontend/src/typings/RootFolder.ts +++ b/frontend/src/typings/RootFolder.ts @@ -1,11 +1,17 @@ import ModelBase from 'App/ModelBase'; +export interface UnmappedFolder { + name: string; + path: string; + relativePath: string; +} + interface RootFolder extends ModelBase { id: number; path: string; accessible: boolean; freeSpace?: number; - unmappedFolders: object[]; + unmappedFolders: UnmappedFolder[]; } export default RootFolder; diff --git a/package.json b/package.json index ae50ea367..b730e784e 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "react-tabs": "4.3.0", "react-text-truncate": "0.19.0", "react-use-measure": "2.1.1", - "react-virtualized": "9.21.1", "react-window": "1.8.10", "redux": "4.2.1", "redux-actions": "2.6.5", @@ -102,7 +101,6 @@ "@types/react-router-dom": "5.3.3", "@types/react-slider": "1.3.6", "@types/react-text-truncate": "0.19.0", - "@types/react-virtualized": "9.22.0", "@types/react-window": "1.8.8", "@types/redux-actions": "2.6.5", "@types/webpack-livereload-plugin": "2.3.6", diff --git a/yarn.lock b/yarn.lock index d44a954e3..daaf7ed5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1479,14 +1479,6 @@ dependencies: "@types/react" "*" -"@types/react-virtualized@9.22.0": - version "9.22.0" - resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.22.0.tgz#2ff9b3692fa04a429df24ffc7d181d9f33b3831d" - integrity sha512-JL/YCCFZ123za//cj10Apk54F0UGFMrjOE0QHTuXt1KBMFrzLOGv9/x6Uc/pZ0Gaf4o6w61Fostvlw0DwuPXig== - dependencies: - "@types/prop-types" "*" - "@types/react" "*" - "@types/react-window@1.8.8": version "1.8.8" resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" @@ -2137,14 +2129,6 @@ babel-plugin-transform-react-remove-prop-types@0.4.24: resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== -babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g== - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - balanced-match@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a" @@ -2377,7 +2361,7 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== -clsx@^1.0.1, clsx@^1.1.0: +clsx@^1.1.0: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== @@ -2514,11 +2498,6 @@ core-js@3.39.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.39.0.tgz#57f7647f4d2d030c32a72ea23a0555b2eaa30f83" integrity sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g== -core-js@^2.4.0: - version "2.6.12" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" - integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== - core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -2820,13 +2799,6 @@ dom-css@^2.0.0: prefix-style "2.0.1" to-camel-case "1.0.0" -"dom-helpers@^2.4.0 || ^3.0.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" - integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== - dependencies: - "@babel/runtime" "^7.1.2" - dom-serializer@^1.0.1: version "1.4.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" @@ -4403,11 +4375,6 @@ line-diff@^2.0.1: dependencies: levdist "^1.0.0" -linear-layout-vector@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/linear-layout-vector/-/linear-layout-vector-0.0.1.tgz#398114d7303b6ecc7fd6b273af7b8401d8ba9c70" - integrity sha512-w+nr1ZOVFGyMhwr8JKo0YzqDc8C2Z7pc9UbTuJA4VG/ezlSFEx+7kNrfCYvq7JQ/jHKR+FWy6reNrkVVzm0hSA== - lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -4544,7 +4511,7 @@ lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -5354,7 +5321,7 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -prop-types@15.8.1, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@15.8.1, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -5558,11 +5525,6 @@ react-lazyload@3.2.0: resolved "https://registry.yarnpkg.com/react-lazyload/-/react-lazyload-3.2.0.tgz#497bd06a6dbd7015e3376e1137a67dc47d2dd021" integrity sha512-zJlrG8QyVZz4+xkYZH5v1w3YaP5wEFaYSUWC4CT9UXfK75IfRAIEdnyIUF+dXr3kX2MOtL1lUaZmaQZqrETwgw== -react-lifecycles-compat@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" - integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== - react-measure@1.4.7: version "1.4.7" resolved "https://registry.yarnpkg.com/react-measure/-/react-measure-1.4.7.tgz#a1d2ca0dcfef04978b7ac263a765dcb6a0936fdb" @@ -5667,19 +5629,6 @@ react-use-measure@2.1.1: dependencies: debounce "^1.2.1" -react-virtualized@9.21.1: - version "9.21.1" - resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.21.1.tgz#4dbbf8f0a1420e2de3abf28fbb77120815277b3a" - integrity sha512-E53vFjRRMCyUTEKuDLuGH1ld/9TFzjf/fFW816PE4HFXWZorESbSTYtiZz1oAjra0MminaUU1EnvUxoGuEFFPA== - dependencies: - babel-runtime "^6.26.0" - clsx "^1.0.1" - dom-helpers "^2.4.0 || ^3.0.0" - linear-layout-vector "0.0.1" - loose-envify "^1.3.0" - prop-types "^15.6.0" - react-lifecycles-compat "^3.0.4" - react-window@1.8.10: version "1.8.10" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" @@ -5833,11 +5782,6 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - regenerator-runtime@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"