From defdc84b7ee1dae0ecbfec7590534684afe893b0 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 27 Mar 2023 16:48:55 -0700 Subject: [PATCH] Convert Manual Import to Typescript --- frontend/.eslintrc.js | 3 +- .../src/Components/Form/FormInputGroup.js | 1 + .../src/Components/Form/PathInputConnector.js | 1 + frontend/src/Components/Link/Link.js | 98 -- frontend/src/Components/Link/Link.tsx | 89 ++ .../Table/Cells/TableRowCellButton.js | 25 - .../Table/Cells/TableRowCellButton.tsx | 19 + frontend/src/Components/Table/Table.js | 1 + frontend/src/Episode/Episode.ts | 29 + frontend/src/Helpers/Hooks/usePrevious.tsx | 11 + .../Episode/SelectEpisodeModal.js | 37 - .../Episode/SelectEpisodeModal.tsx | 48 + .../Episode/SelectEpisodeModalContent.css | 4 +- .../SelectEpisodeModalContent.css.d.ts | 2 +- .../Episode/SelectEpisodeModalContent.js | 245 ----- .../Episode/SelectEpisodeModalContent.tsx | 278 ++++++ .../SelectEpisodeModalContentConnector.js | 122 --- ...teractiveImportSelectFolderModalContent.js | 170 ---- ...eractiveImportSelectFolderModalContent.tsx | 172 ++++ ...ImportSelectFolderModalContentConnector.js | 80 -- .../InteractiveImport/Folder/RecentFolder.ts | 6 + frontend/src/InteractiveImport/ImportMode.ts | 7 + .../InteractiveImportModalContent.js | 568 ------------ .../InteractiveImportModalContent.tsx | 873 ++++++++++++++++++ .../InteractiveImportModalContentConnector.js | 327 ------- .../Interactive/InteractiveImportRow.js | 504 ---------- .../Interactive/InteractiveImportRow.tsx | 506 ++++++++++ .../InteractiveImport/InteractiveImport.ts | 25 + .../InteractiveImportModal.js | 86 -- .../InteractiveImportModal.tsx | 73 ++ .../Language/SelectLanguageModal.js | 39 - .../Language/SelectLanguageModal.tsx | 31 + .../Language/SelectLanguageModalContent.js | 154 --- .../Language/SelectLanguageModalContent.tsx | 119 +++ .../SelectLanguageModalContentConnector.js | 96 -- .../Quality/SelectQualityModal.js | 37 - .../Quality/SelectQualityModal.tsx | 41 + .../Quality/SelectQualityModalContent.js | 168 ---- .../Quality/SelectQualityModalContent.tsx | 169 ++++ .../SelectQualityModalContentConnector.js | 105 --- .../ReleaseGroup/SelectReleaseGroupModal.js | 37 - .../ReleaseGroup/SelectReleaseGroupModal.tsx | 34 + .../SelectReleaseGroupModalContent.js | 105 --- .../SelectReleaseGroupModalContent.tsx | 72 ++ ...SelectReleaseGroupModalContentConnector.js | 54 -- .../Season/SelectSeasonModal.js | 37 - .../Season/SelectSeasonModal.tsx | 28 + .../Season/SelectSeasonModalContent.js | 60 -- .../Season/SelectSeasonModalContent.tsx | 48 + .../SelectSeasonModalContentConnector.js | 68 -- .../Season/SelectSeasonRow.js | 40 - .../Season/SelectSeasonRow.tsx | 28 + .../Series/SelectSeriesModal.js | 37 - .../Series/SelectSeriesModal.tsx | 27 + .../Series/SelectSeriesModalContent.js | 105 --- .../Series/SelectSeriesModalContent.tsx | 92 ++ .../SelectSeriesModalContentConnector.js | 86 -- frontend/src/Language/Language.ts | 6 + frontend/src/Quality/Quality.ts | 30 + .../Store/Actions/episodeSelectionActions.js | 61 ++ frontend/src/Store/Actions/index.js | 2 + .../Store/Actions/interactiveImportActions.js | 37 +- frontend/src/typings/Rejection.ts | 11 + 63 files changed, 2946 insertions(+), 3528 deletions(-) delete mode 100644 frontend/src/Components/Link/Link.js create mode 100644 frontend/src/Components/Link/Link.tsx delete mode 100644 frontend/src/Components/Table/Cells/TableRowCellButton.js create mode 100644 frontend/src/Components/Table/Cells/TableRowCellButton.tsx create mode 100644 frontend/src/Episode/Episode.ts create mode 100644 frontend/src/Helpers/Hooks/usePrevious.tsx delete mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js create mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModal.tsx delete mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js create mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js delete mode 100644 frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js create mode 100644 frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Folder/RecentFolder.ts create mode 100644 frontend/src/InteractiveImport/ImportMode.ts delete mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js delete mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx create mode 100644 frontend/src/InteractiveImport/InteractiveImport.ts delete mode 100644 frontend/src/InteractiveImport/InteractiveImportModal.js create mode 100644 frontend/src/InteractiveImport/InteractiveImportModal.tsx delete mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModal.js create mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModal.tsx delete mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js create mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js delete mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModal.js create mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModal.tsx delete mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js create mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js delete mode 100644 frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.js create mode 100644 frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.tsx delete mode 100644 frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.js create mode 100644 frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContentConnector.js delete mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModal.js create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModal.tsx delete mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js delete mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonRow.js create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonRow.tsx delete mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModal.js create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModal.tsx delete mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModalContent.tsx delete mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js create mode 100644 frontend/src/Language/Language.ts create mode 100644 frontend/src/Quality/Quality.ts create mode 100644 frontend/src/Store/Actions/episodeSelectionActions.js create mode 100644 frontend/src/typings/Rejection.ts diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 99609a2da..a881f4a31 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -24,7 +24,8 @@ module.exports = { globals: { expect: false, chai: false, - sinon: false + sinon: false, + JSX: true }, parserOptions: { diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 9967f3d37..c3dbd0165 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -267,6 +267,7 @@ FormInputGroup.propTypes = { helpTexts: PropTypes.arrayOf(PropTypes.string), helpTextWarning: PropTypes.string, helpLink: PropTypes.string, + autoFocus: PropTypes.bool, includeNoChange: PropTypes.bool, includeNoChangeDisabled: PropTypes.bool, selectedValueOptions: PropTypes.object, diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js index 3917a8d3f..563437f9a 100644 --- a/frontend/src/Components/Form/PathInputConnector.js +++ b/frontend/src/Components/Form/PathInputConnector.js @@ -68,6 +68,7 @@ class PathInputConnector extends Component { } PathInputConnector.propTypes = { + ...PathInput.props, includeFiles: PropTypes.bool.isRequired, dispatchFetchPaths: PropTypes.func.isRequired, dispatchClearPaths: PropTypes.func.isRequired diff --git a/frontend/src/Components/Link/Link.js b/frontend/src/Components/Link/Link.js deleted file mode 100644 index 23d5fe233..000000000 --- a/frontend/src/Components/Link/Link.js +++ /dev/null @@ -1,98 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; -import styles from './Link.css'; - -class Link extends Component { - - // - // Listeners - - onClick = (event) => { - const { - isDisabled, - onPress - } = this.props; - - if (!isDisabled && onPress) { - onPress(event); - } - }; - - // - // Render - - render() { - const { - className, - component, - to, - target, - isDisabled, - noRouter, - onPress, - ...otherProps - } = this.props; - - const linkProps = { target }; - let el = component; - - if (to) { - if ((/\w+?:\/\//).test(to)) { - el = 'a'; - linkProps.href = to; - linkProps.target = target || '_blank'; - linkProps.rel = 'noreferrer'; - } else if (noRouter) { - el = 'a'; - linkProps.href = to; - linkProps.target = target || '_self'; - } else { - el = RouterLink; - linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`; - linkProps.target = target; - } - } - - if (el === 'button' || el === 'input') { - linkProps.type = otherProps.type || 'button'; - linkProps.disabled = isDisabled; - } - - linkProps.className = classNames( - className, - styles.link, - to && styles.to, - isDisabled && 'isDisabled' - ); - - const props = { - ...otherProps, - ...linkProps - }; - - props.onClick = this.onClick; - - return ( - React.createElement(el, props) - ); - } -} - -Link.propTypes = { - className: PropTypes.string, - component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), - to: PropTypes.string, - target: PropTypes.string, - isDisabled: PropTypes.bool, - noRouter: PropTypes.bool, - onPress: PropTypes.func -}; - -Link.defaultProps = { - component: 'button', - noRouter: false -}; - -export default Link; diff --git a/frontend/src/Components/Link/Link.tsx b/frontend/src/Components/Link/Link.tsx new file mode 100644 index 000000000..2ad0b2d2c --- /dev/null +++ b/frontend/src/Components/Link/Link.tsx @@ -0,0 +1,89 @@ +import classNames from 'classnames'; +import React, { ComponentClass, FunctionComponent, useCallback } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import styles from './Link.css'; + +interface ReactRouterLinkProps { + to?: string; +} + +export interface LinkProps extends React.HTMLProps { + className?: string; + component?: + | string + | FunctionComponent + | ComponentClass; + to?: string; + target?: string; + isDisabled?: boolean; + noRouter?: boolean; + onPress?(event: Event): void; +} +function Link(props: LinkProps) { + const { + className, + component = 'button', + to, + target, + type, + isDisabled, + noRouter = false, + onPress, + ...otherProps + } = props; + + const onClick = useCallback( + (event) => { + if (!isDisabled && onPress) { + onPress(event); + } + }, + [isDisabled, onPress] + ); + + const linkProps: React.HTMLProps & ReactRouterLinkProps = { + target, + }; + let el = component; + + if (to) { + if (/\w+?:\/\//.test(to)) { + el = 'a'; + linkProps.href = to; + linkProps.target = target || '_blank'; + linkProps.rel = 'noreferrer'; + } else if (noRouter) { + el = 'a'; + linkProps.href = to; + linkProps.target = target || '_self'; + } else { + el = RouterLink; + linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`; + linkProps.target = target; + } + } + + if (el === 'button' || el === 'input') { + linkProps.type = type || 'button'; + linkProps.disabled = isDisabled; + } + + linkProps.className = classNames( + className, + styles.link, + to && styles.to, + isDisabled && 'isDisabled' + ); + + const elementProps = { + ...otherProps, + type, + ...linkProps, + }; + + elementProps.onClick = onClick; + + return React.createElement(el, elementProps); +} + +export default Link; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js deleted file mode 100644 index ff50d3bc9..000000000 --- a/frontend/src/Components/Table/Cells/TableRowCellButton.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Link from 'Components/Link/Link'; -import TableRowCell from './TableRowCell'; -import styles from './TableRowCellButton.css'; - -function TableRowCellButton({ className, ...otherProps }) { - return ( - - ); -} - -TableRowCellButton.propTypes = { - className: PropTypes.string.isRequired -}; - -TableRowCellButton.defaultProps = { - className: styles.cell -}; - -export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.tsx b/frontend/src/Components/Table/Cells/TableRowCellButton.tsx new file mode 100644 index 000000000..c80a3d626 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.tsx @@ -0,0 +1,19 @@ +import React, { ReactNode } from 'react'; +import Link, { LinkProps } from 'Components/Link/Link'; +import TableRowCell from './TableRowCell'; +import styles from './TableRowCellButton.css'; + +interface TableRowCellButtonProps extends LinkProps { + className?: string; + children: ReactNode; +} + +function TableRowCellButton(props: TableRowCellButtonProps) { + const { className = styles.cell, ...otherProps } = props; + + return ( + + ); +} + +export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js index c41fc982a..befc8219a 100644 --- a/frontend/src/Components/Table/Table.js +++ b/frontend/src/Components/Table/Table.js @@ -121,6 +121,7 @@ function Table(props) { } Table.propTypes = { + ...TableHeaderCell.props, className: PropTypes.string, horizontalScroll: PropTypes.bool.isRequired, selectAll: PropTypes.bool.isRequired, diff --git a/frontend/src/Episode/Episode.ts b/frontend/src/Episode/Episode.ts new file mode 100644 index 000000000..5df98e889 --- /dev/null +++ b/frontend/src/Episode/Episode.ts @@ -0,0 +1,29 @@ +import ModelBase from 'App/ModelBase'; +import Series from 'Series/Series'; + +interface Episode extends ModelBase { + seriesId: number; + tvdbId: number; + episodeFileId: number; + seasonNumber: number; + episodeNumber: number; + airDate: string; + airDateUtc?: string; + runtime: number; + absoluteEpisodeNumber?: number; + sceneSeasonNumber?: number; + sceneEpisodeNumber?: number; + sceneAbsoluteEpisodeNumber?: number; + overview: string; + title: string; + episodeFile?: object; + hasFile: boolean; + monitored: boolean; + unverifiedSceneNumbering: boolean; + endTime?: string; + grabDate?: string; + seriesTitle?: string; + series?: Series; +} + +export default Episode; diff --git a/frontend/src/Helpers/Hooks/usePrevious.tsx b/frontend/src/Helpers/Hooks/usePrevious.tsx new file mode 100644 index 000000000..b594e2632 --- /dev/null +++ b/frontend/src/Helpers/Hooks/usePrevious.tsx @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +export default function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js deleted file mode 100644 index 31ea74d23..000000000 --- a/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import SelectEpisodeModalContentConnector from './SelectEpisodeModalContentConnector'; - -class SelectEpisodeModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -SelectEpisodeModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectEpisodeModal; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.tsx b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.tsx new file mode 100644 index 000000000..bb4fd8165 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectEpisodeModalContent, { + SelectedEpisode, +} from './SelectEpisodeModalContent'; + +interface SelectEpisodeModalProps { + isOpen: boolean; + selectedIds: number[] | string[]; + seriesId: number; + seasonNumber: number; + selectedDetails?: string; + isAnime: boolean; + modalTitle: string; + onEpisodesSelect(selectedEpisodes: SelectedEpisode[]): void; + onModalClose(): void; +} + +function SelectEpisodeModal(props: SelectEpisodeModalProps) { + const { + isOpen, + selectedIds, + seriesId, + seasonNumber, + selectedDetails, + isAnime, + modalTitle, + onEpisodesSelect, + onModalClose, + } = props; + + return ( + + + + ); +} + +export default SelectEpisodeModal; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css index b8c9334c0..5e1f4d0fa 100644 --- a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css @@ -25,7 +25,7 @@ overflow: hidden; } -.path { +.details { margin-right: 20px; color: var(--dimColor); word-break: break-word; @@ -40,7 +40,7 @@ display: block; } - .path { + .details { margin-right: 0; margin-bottom: 10px; } diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css.d.ts b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css.d.ts index 4f4df1647..b567737bd 100644 --- a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css.d.ts +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.css.d.ts @@ -2,10 +2,10 @@ // Please do not change this file! interface CssExports { 'buttons': string; + 'details': string; 'filterInput': string; 'footer': string; 'modalBody': string; - 'path': string; 'scroller': string; } export const cssExports: CssExports; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js deleted file mode 100644 index b50eeadc9..000000000 --- a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js +++ /dev/null @@ -1,245 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextInput from 'Components/Form/TextInput'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import Scroller from 'Components/Scroller/Scroller'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { kinds, scrollDirections } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import SelectEpisodeRow from './SelectEpisodeRow'; -import styles from './SelectEpisodeModalContent.css'; - -const columns = [ - { - name: 'episodeNumber', - label: '#', - isSortable: true, - isVisible: true - }, - { - name: 'title', - label: 'Title', - isVisible: true - }, - { - name: 'airDate', - label: 'Air Date', - isVisible: true - } -]; - -class SelectEpisodeModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - allSelected: false, - allUnselected: false, - filter: '', - lastToggled: null, - selectedState: {} - }; - } - - // - // Control - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState); - }; - - // - // Listeners - - onFilterChange = ({ value }) => { - this.setState({ filter: value.toLowerCase() }); - }; - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onEpisodesSelect = () => { - this.props.onEpisodesSelect(this.getSelectedIds()); - }; - - // - // Render - - render() { - const { - ids, - isFetching, - isPopulated, - error, - items, - relativePath, - isAnime, - sortKey, - sortDirection, - modalTitle, - onSortPress, - onModalClose - } = this.props; - - const { - allSelected, - allUnselected, - filter, - selectedState - } = this.state; - const filterEpisodeNumber = parseInt(filter); - - const errorMessage = getErrorMessage(error, 'Unable to load episodes'); - - const selectedFilesCount = ids.length; - const selectedCount = this.getSelectedIds().length; - const selectionIsValid = ( - selectedCount > 0 && - selectedCount % selectedFilesCount === 0 - ); - - return ( - - -
- {modalTitle} - Select Episode(s) -
- -
- - - - - - { - isFetching ? : null - } - - { - error ?
{errorMessage}
: null - } - - { - isPopulated && !!items.length ? - - - { - items.map((item) => { - return item.title.toLowerCase().includes(filter) || - item.episodeNumber === filterEpisodeNumber ? - ( - - ) : - null; - }) - } - -
: - null - } - - { - isPopulated && !items.length ? - 'No episodes were found for the selected season' : - null - } -
-
- - -
- { - relativePath ? - relativePath : - `${selectedFilesCount} selected files` - } -
- -
- - - -
-
-
- ); - } -} - -SelectEpisodeModalContent.propTypes = { - ids: PropTypes.arrayOf(PropTypes.number).isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - relativePath: PropTypes.string, - isAnime: PropTypes.bool.isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.string, - modalTitle: PropTypes.string, - onSortPress: PropTypes.func.isRequired, - onEpisodesSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectEpisodeModalContent; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.tsx b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.tsx new file mode 100644 index 000000000..7e5be5134 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.tsx @@ -0,0 +1,278 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import TextInput from 'Components/Form/TextInput'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Scroller from 'Components/Scroller/Scroller'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import Episode from 'Episode/Episode'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import { + clearEpisodes, + fetchEpisodes, + setEpisodesSort, +} from 'Store/Actions/episodeSelectionActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import SelectEpisodeRow from './SelectEpisodeRow'; +import styles from './SelectEpisodeModalContent.css'; + +const columns = [ + { + name: 'episodeNumber', + label: '#', + isSortable: true, + isVisible: true, + }, + { + name: 'title', + label: 'Title', + isVisible: true, + }, + { + name: 'airDate', + label: 'Air Date', + isVisible: true, + }, +]; + +function episodesSelector() { + return createSelector( + createClientSideCollectionSelector('episodeSelection'), + (episodes) => { + return episodes; + } + ); +} + +export interface SelectedEpisode { + fileId: number; + episodes: Episode[]; +} + +interface SelectEpisodeModalContentProps { + selectedIds: number[] | string[]; + seriesId: number; + seasonNumber: number; + selectedDetails?: string; + isAnime: boolean; + sortKey?: string; + sortDirection?: string; + modalTitle?: string; + onEpisodesSelect(selectedEpisodes: SelectedEpisode[]): unknown; + onModalClose(): unknown; +} + +// +// Render + +function SelectEpisodeModalContent(props: SelectEpisodeModalContentProps) { + const { + selectedIds, + seriesId, + seasonNumber, + selectedDetails, + isAnime, + sortKey, + sortDirection, + modalTitle, + onEpisodesSelect, + onModalClose, + } = props; + + const [filter, setFilter] = useState(''); + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + const { isFetching, isPopulated, items, error } = useSelector( + episodesSelector() + ); + const dispatch = useDispatch(); + + const filterEpisodeNumber = parseInt(filter); + const errorMessage = getErrorMessage(error, 'Unable to load episodes'); + const selectedCount = selectedIds.length; + const selectedEpisodesCount = getSelectedIds(selectState).length; + const selectionIsValid = + selectedEpisodesCount > 0 && selectedEpisodesCount % selectedCount === 0; + + const onFilterChange = useCallback( + ({ value }) => { + setFilter(value.toLowerCase()); + }, + [setFilter] + ); + + const onSelectAllChange = useCallback( + ({ value }) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const onSortPress = useCallback( + (newSortKey, newSortDirection) => { + dispatch( + setEpisodesSort({ + sortKey: newSortKey, + sortDirection: newSortDirection, + }) + ); + }, + [dispatch] + ); + + const onEpisodesSelectWrapper = useCallback(() => { + const episodeIds = getSelectedIds(selectedState); + + const selectedEpisodes = items.reduce((acc, item) => { + if (episodeIds.indexOf(item.id) > -1) { + acc.push(item); + } + + return acc; + }, []); + + const episodesPerFile = selectedEpisodes.length / selectedIds.length; + const sortedEpisodes = selectedEpisodes.sort((a, b) => { + return a.seasonNumber - b.seasonNumber; + }); + + const mappedEpisodes = selectedIds.map((fileId, index): SelectedEpisode => { + const startingIndex = index * episodesPerFile; + const episodes = sortedEpisodes.slice( + startingIndex, + startingIndex + episodesPerFile + ); + + return { + fileId, + episodes, + }; + }); + + onEpisodesSelect(mappedEpisodes); + }, [selectedIds, items, selectedState, onEpisodesSelect]); + + useEffect( + () => { + dispatch(fetchEpisodes({ seriesId, seasonNumber })); + + return () => { + dispatch(clearEpisodes()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + let details = selectedDetails; + + if (!details) { + details = + selectedCount > 1 + ? `${selectedCount} selected files` + : `${selectedCount} selected file`; + } + + return ( + + {modalTitle} - Select Episode(s) + + + + + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length ? ( + + + {items.map((item) => { + return item.title.toLowerCase().includes(filter) || + item.episodeNumber === filterEpisodeNumber ? ( + + ) : null; + })} + +
+ ) : null} + + {isPopulated && !items.length + ? 'No episodes were found for the selected season' + : null} +
+
+ + +
{details}
+ +
+ + + +
+
+
+ ); +} + +export default SelectEpisodeModalContent; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js deleted file mode 100644 index 13225f6cf..000000000 --- a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js +++ /dev/null @@ -1,122 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { - clearInteractiveImportEpisodes, - fetchInteractiveImportEpisodes, - reprocessInteractiveImportItems, - setInteractiveImportEpisodesSort, - updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import SelectEpisodeModalContent from './SelectEpisodeModalContent'; - -function createMapStateToProps() { - return createSelector( - createClientSideCollectionSelector('interactiveImport.episodes'), - (episodes) => { - return episodes; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchInteractiveImportEpisodes: fetchInteractiveImportEpisodes, - dispatchSetInteractiveImportEpisodesSort: setInteractiveImportEpisodesSort, - dispatchClearInteractiveImportEpisodes: clearInteractiveImportEpisodes, - dispatchUpdateInteractiveImportItem: updateInteractiveImportItem, - dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems -}; - -class SelectEpisodeModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - seriesId, - seasonNumber - } = this.props; - - this.props.dispatchFetchInteractiveImportEpisodes({ seriesId, seasonNumber }); - } - - componentWillUnmount() { - // This clears the episodes for the queue and hides the queue - // We'll need another place to store episodes for manual import - this.props.dispatchClearInteractiveImportEpisodes(); - } - - // - // Listeners - - onSortPress = (sortKey, sortDirection) => { - this.props.dispatchSetInteractiveImportEpisodesSort({ sortKey, sortDirection }); - }; - - onEpisodesSelect = (episodeIds) => { - const { - ids, - items, - dispatchUpdateInteractiveImportItem, - dispatchReprocessInteractiveImportItems, - onModalClose - } = this.props; - - const selectedEpisodes = items.reduce((acc, item) => { - if (episodeIds.indexOf(item.id) > -1) { - acc.push(item); - } - - return acc; - }, []); - - const episodesPerFile = selectedEpisodes.length / ids.length; - const sortedEpisodes = selectedEpisodes.sort((a, b) => { - return a.seasonNumber - b.seasonNumber; - }); - - ids.forEach((id, index) => { - const startingIndex = index * episodesPerFile; - const episodes = sortedEpisodes.slice(startingIndex, startingIndex + episodesPerFile); - - dispatchUpdateInteractiveImportItem({ - id, - episodes - }); - }); - - dispatchReprocessInteractiveImportItems({ ids }); - - onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -SelectEpisodeModalContentConnector.propTypes = { - ids: PropTypes.arrayOf(PropTypes.number).isRequired, - seriesId: PropTypes.number.isRequired, - seasonNumber: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - dispatchFetchInteractiveImportEpisodes: PropTypes.func.isRequired, - dispatchSetInteractiveImportEpisodesSort: PropTypes.func.isRequired, - dispatchClearInteractiveImportEpisodes: PropTypes.func.isRequired, - dispatchUpdateInteractiveImportItem: PropTypes.func.isRequired, - dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(SelectEpisodeModalContentConnector); diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js deleted file mode 100644 index 4bc8ac30d..000000000 --- a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js +++ /dev/null @@ -1,170 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import PathInputConnector from 'Components/Form/PathInputConnector'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { icons, kinds, sizes } from 'Helpers/Props'; -import RecentFolderRow from './RecentFolderRow'; -import styles from './InteractiveImportSelectFolderModalContent.css'; - -const recentFoldersColumns = [ - { - name: 'folder', - label: 'Folder' - }, - { - name: 'lastUsed', - label: 'Last Used' - }, - { - name: 'actions', - label: '' - } -]; - -class InteractiveImportSelectFolderModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - folder: '' - }; - } - - // - // Listeners - - onPathChange = ({ value }) => { - this.setState({ folder: value }); - }; - - onRecentPathPress = (folder) => { - this.setState({ folder }); - }; - - onQuickImportPress = () => { - this.props.onQuickImportPress(this.state.folder); - }; - - onInteractiveImportPress = () => { - this.props.onInteractiveImportPress(this.state.folder); - }; - - // - // Render - - render() { - const { - recentFolders, - onRemoveRecentFolderPress, - modalTitle, - onModalClose - } = this.props; - - const folder = this.state.folder; - - return ( - - - {modalTitle} - Select Folder - - - - - - { - !!recentFolders.length && -
- - - { - recentFolders.slice(0).reverse().map((recentFolder) => { - return ( - - ); - }) - } - -
-
- } - -
-
- -
- -
- -
-
-
- - - - -
- ); - } -} - -InteractiveImportSelectFolderModalContent.propTypes = { - recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired, - modalTitle: PropTypes.string.isRequired, - onQuickImportPress: PropTypes.func.isRequired, - onInteractiveImportPress: PropTypes.func.isRequired, - onRemoveRecentFolderPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default InteractiveImportSelectFolderModalContent; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx new file mode 100644 index 000000000..4f7a9123e --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx @@ -0,0 +1,172 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as commandNames from 'Commands/commandNames'; +import PathInputConnector from 'Components/Form/PathInputConnector'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { + addRecentFolder, + removeRecentFolder, +} from 'Store/Actions/interactiveImportActions'; +import translate from 'Utilities/String/translate'; +import RecentFolder from './RecentFolder'; +import RecentFolderRow from './RecentFolderRow'; +import styles from './InteractiveImportSelectFolderModalContent.css'; + +const recentFoldersColumns = [ + { + name: 'folder', + label: 'Folder', + }, + { + name: 'lastUsed', + label: 'Last Used', + }, + { + name: 'actions', + label: '', + }, +]; + +interface InteractiveImportSelectFolderModalContentProps { + modalTitle: string; + onFolderSelect(folder: string): void; + onModalClose(): void; +} + +function InteractiveImportSelectFolderModalContent( + props: InteractiveImportSelectFolderModalContentProps +) { + const { modalTitle, onFolderSelect, onModalClose } = props; + const [folder, setFolder] = useState(''); + const dispatch = useDispatch(); + const recentFolders: RecentFolder[] = useSelector( + createSelector( + (state) => state.interactiveImport.recentFolders, + (recentFolders) => { + return recentFolders; + } + ) + ); + + const onPathChange = useCallback( + ({ value }) => { + setFolder(value); + }, + [setFolder] + ); + + const onRecentPathPress = useCallback( + (value) => { + setFolder(value); + }, + [setFolder] + ); + + const onQuickImportPress = useCallback(() => { + dispatch(addRecentFolder({ folder })); + + dispatch( + executeCommand({ + name: commandNames.DOWNLOADED_EPSIODES_SCAN, + path: folder, + }) + ); + + onModalClose(); + }, [folder, onModalClose, dispatch]); + + const onInteractiveImportPress = useCallback(() => { + dispatch(addRecentFolder({ folder })); + onFolderSelect(folder); + }, [folder, onFolderSelect, dispatch]); + + const onRemoveRecentFolderPress = useCallback( + (f) => { + dispatch(removeRecentFolder({ folder: f })); + }, + [dispatch] + ); + + return ( + + + {modalTitle} - {translate('Select Folder')} + + + + + + {recentFolders.length ? ( +
+ + + {recentFolders + .slice(0) + .reverse() + .map((recentFolder) => { + return ( + + ); + })} + +
+
+ ) : null} + +
+
+ +
+ +
+ +
+
+
+ + + + +
+ ); +} + +export default InteractiveImportSelectFolderModalContent; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js deleted file mode 100644 index 216641fbe..000000000 --- a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js +++ /dev/null @@ -1,80 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions'; -import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.interactiveImport.recentFolders, - (recentFolders) => { - return { - recentFolders - }; - } - ); -} - -const mapDispatchToProps = { - addRecentFolder, - removeRecentFolder, - executeCommand -}; - -class InteractiveImportSelectFolderModalContentConnector extends Component { - - // - // Listeners - - onQuickImportPress = (folder) => { - this.props.addRecentFolder({ folder }); - - this.props.executeCommand({ - name: commandNames.DOWNLOADED_EPSIODES_SCAN, - path: folder - }); - - this.props.onModalClose(); - }; - - onInteractiveImportPress = (folder) => { - this.props.addRecentFolder({ folder }); - this.props.onFolderSelect(folder); - }; - - onRemoveRecentFolderPress = (folder) => { - this.props.removeRecentFolder({ folder }); - }; - - // - // Render - - render() { - if (this.path) { - return null; - } - - return ( - - ); - } -} - -InteractiveImportSelectFolderModalContentConnector.propTypes = { - path: PropTypes.string, - onFolderSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired, - addRecentFolder: PropTypes.func.isRequired, - removeRecentFolder: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector); diff --git a/frontend/src/InteractiveImport/Folder/RecentFolder.ts b/frontend/src/InteractiveImport/Folder/RecentFolder.ts new file mode 100644 index 000000000..9c6e295f6 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolder.ts @@ -0,0 +1,6 @@ +interface RecentFolder { + folder: string; + lastUsed: string; +} + +export default RecentFolder; diff --git a/frontend/src/InteractiveImport/ImportMode.ts b/frontend/src/InteractiveImport/ImportMode.ts new file mode 100644 index 000000000..0c8ee6472 --- /dev/null +++ b/frontend/src/InteractiveImport/ImportMode.ts @@ -0,0 +1,7 @@ +enum ImportMode { + Auto = 'auto', + Move = 'move', + Copy = 'copy', +} + +export default ImportMode; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js deleted file mode 100644 index 7f87d84b8..000000000 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ /dev/null @@ -1,568 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import SelectInput from 'Components/Form/SelectInput'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Menu from 'Components/Menu/Menu'; -import MenuButton from 'Components/Menu/MenuButton'; -import MenuContent from 'Components/Menu/MenuContent'; -import SelectedMenuItem from 'Components/Menu/SelectedMenuItem'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { align, icons, kinds, scrollDirections } from 'Helpers/Props'; -import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; -import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; -import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; -import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; -import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; -import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import InteractiveImportRow from './InteractiveImportRow'; -import styles from './InteractiveImportModalContent.css'; - -const columns = [ - { - name: 'relativePath', - label: 'Relative Path', - isSortable: true, - isVisible: true - }, - { - name: 'series', - label: 'Series', - isSortable: true, - isVisible: true - }, - { - name: 'season', - label: 'Season', - isVisible: true - }, - { - name: 'episodes', - label: 'Episode(s)', - isVisible: true - }, - { - name: 'releaseGroup', - label: 'Release Group', - isVisible: true - }, - { - name: 'quality', - label: 'Quality', - isSortable: true, - isVisible: true - }, - { - name: 'languages', - label: 'Languages', - isSortable: true, - isVisible: true - }, - { - name: 'size', - label: 'Size', - isSortable: true, - isVisible: true - }, - { - name: 'customFormats', - label: React.createElement(Icon, { - name: icons.INTERACTIVE, - title: 'Custom Format' - }), - isSortable: true, - isVisible: true - }, - { - name: 'rejections', - label: React.createElement(Icon, { - name: icons.DANGER, - kind: kinds.DANGER - }), - isSortable: true, - isVisible: true - } -]; - -const filterExistingFilesOptions = { - ALL: 'all', - NEW: 'new' -}; - -const importModeOptions = [ - { key: 'chooseImportMode', value: 'Choose Import Mode', disabled: true }, - { key: 'move', value: 'Move Files' }, - { key: 'copy', value: 'Hardlink/Copy Files' } -]; - -const SELECT = 'select'; -const SERIES = 'series'; -const SEASON = 'season'; -const EPISODE = 'episode'; -const RELEASE_GROUP = 'releaseGroup'; -const QUALITY = 'quality'; -const LANGUAGE = 'language'; - -class InteractiveImportModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const instanceColumns = _.cloneDeep(columns); - - if (!props.showSeries) { - instanceColumns.find((c) => c.name === 'series').isVisible = false; - } - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - invalidRowsSelected: [], - withoutEpisodeFileIdRowsSelected: [], - selectModalOpen: null, - columns: instanceColumns, - isConfirmDeleteModalOpen: false - }; - } - - componentDidUpdate(prevProps) { - const { - isDeleting, - deleteError, - onModalClose - } = this.props; - - if (!isDeleting && prevProps.isDeleting && !deleteError) { - onModalClose(); - } - } - - // - // Control - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState); - }; - - // - // Listeners - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, hasEpisodeFileId, shiftKey = false }) => { - this.setState((state) => { - return { - ...toggleSelected(state, this.props.items, id, value, shiftKey), - withoutEpisodeFileIdRowsSelected: hasEpisodeFileId || !value ? - _.without(state.withoutEpisodeFileIdRowsSelected, id) : - [...state.withoutEpisodeFileIdRowsSelected, id] - }; - }); - }; - - onValidRowChange = (id, isValid) => { - this.setState((state) => { - if (isValid) { - return { - invalidRowsSelected: _.without(state.invalidRowsSelected, id) - }; - } - - return { - invalidRowsSelected: [...state.invalidRowsSelected, id] - }; - }); - }; - - onDeleteSelectedPress = () => { - this.setState({ isConfirmDeleteModalOpen: true }); - }; - - onConfirmDelete = () => { - this.setState({ isConfirmDeleteModalOpen: false }); - this.props.onDeleteSelectedPress(this.getSelectedIds()); - }; - - onConfirmDeleteModalClose = () => { - this.setState({ isConfirmDeleteModalOpen: false }); - }; - - onImportSelectedPress = () => { - const { - downloadId, - showImportMode, - importMode, - onImportSelectedPress - } = this.props; - - const selected = this.getSelectedIds(); - const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode; - - onImportSelectedPress(selected, finalImportMode); - }; - - onFilterExistingFilesChange = (value) => { - this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); - }; - - onImportModeChange = ({ value }) => { - this.props.onImportModeChange(value); - }; - - onSelectModalSelect = ({ value }) => { - this.setState({ selectModalOpen: value }); - }; - - onSelectModalClose = () => { - this.setState({ selectModalOpen: null }); - }; - - // - // Render - - render() { - const { - downloadId, - allowSeriesChange, - autoSelectRow, - showFilterExistingFiles, - showDelete, - showImportMode, - filterExistingFiles, - title, - folder, - isFetching, - isPopulated, - error, - items, - sortKey, - sortDirection, - importMode, - interactiveImportErrorMessage, - isDeleting, - modalTitle, - onSortPress, - onModalClose - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - invalidRowsSelected, - withoutEpisodeFileIdRowsSelected, - selectModalOpen, - isConfirmDeleteModalOpen - } = this.state; - - const selectedIds = this.getSelectedIds(); - - const orderedSelectedIds = items.reduce((acc, file) => { - if (selectedIds.includes(file.id)) { - acc.push(file.id); - } - - return acc; - }, []); - - const selectedItem = selectedIds.length ? - items.find((file) => file.id === selectedIds[0]) : - null; - - const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); - - const bulkSelectOptions = [ - { key: SELECT, value: 'Select...', disabled: true }, - { key: SEASON, value: 'Select Season' }, - { key: EPISODE, value: 'Select Episode(s)' }, - { key: QUALITY, value: 'Select Quality' }, - { key: RELEASE_GROUP, value: 'Select Release Group' }, - { key: LANGUAGE, value: 'Select Language' } - ]; - - if (allowSeriesChange) { - bulkSelectOptions.splice(1, 0, { - key: SERIES, - value: 'Select Series' - }); - } - - return ( - - - {modalTitle} - {title || folder} - - - - { - showFilterExistingFiles && -
- - - - -
- { - filterExistingFiles ? 'Unmapped Files Only' : 'All Files' - } -
-
- - - - All Files - - - - Unmapped Files Only - - -
-
- } - - { - isFetching && - - } - - { - error && -
{errorMessage}
- } - - { - isPopulated && !!items.length && !isFetching && !isFetching && - - - { - items.map((item) => { - return ( - - ); - }) - } - -
- } - - { - isPopulated && !items.length && !isFetching && - 'No video files were found in the selected folder' - } -
- - -
- { - showDelete ? - - Delete - : - null - } - - { - !downloadId && showImportMode ? - : - null - } - - -
- -
- - - { - interactiveImportErrorMessage && - {interactiveImportErrorMessage} - } - - -
-
- - - - - - - - - - - - - - -
- ); - } -} - -InteractiveImportModalContent.propTypes = { - downloadId: PropTypes.string, - showSeries: PropTypes.bool.isRequired, - allowSeriesChange: PropTypes.bool.isRequired, - autoSelectRow: PropTypes.bool.isRequired, - showDelete: PropTypes.bool.isRequired, - showImportMode: PropTypes.bool.isRequired, - showFilterExistingFiles: PropTypes.bool.isRequired, - filterExistingFiles: PropTypes.bool.isRequired, - importMode: PropTypes.string.isRequired, - title: PropTypes.string, - folder: PropTypes.string, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.string, - interactiveImportErrorMessage: PropTypes.string, - isDeleting: PropTypes.bool.isRequired, - deleteError: PropTypes.object, - modalTitle: PropTypes.string.isRequired, - onSortPress: PropTypes.func.isRequired, - onFilterExistingFilesChange: PropTypes.func.isRequired, - onImportModeChange: PropTypes.func.isRequired, - onDeleteSelectedPress: PropTypes.func.isRequired, - onImportSelectedPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -InteractiveImportModalContent.defaultProps = { - showSeries: true, - allowSeriesChange: true, - autoSelectRow: true, - showFilterExistingFiles: false, - showDelete: false, - showImportMode: true, - importMode: 'move' -}; - -export default InteractiveImportModalContent; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx new file mode 100644 index 000000000..122d24568 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -0,0 +1,873 @@ +import { cloneDeep, without } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as commandNames from 'Commands/commandNames'; +import SelectInput from 'Components/Form/SelectInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import SelectedMenuItem from 'Components/Menu/SelectedMenuItem'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { align, icons, kinds, scrollDirections } from 'Helpers/Props'; +import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; +import ImportMode from 'InteractiveImport/ImportMode'; +import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; +import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { + deleteEpisodeFiles, + updateEpisodeFiles, +} from 'Store/Actions/episodeFileActions'; +import { + clearInteractiveImport, + fetchInteractiveImportItems, + reprocessInteractiveImportItems, + setInteractiveImportMode, + setInteractiveImportSort, + updateInteractiveImportItems, +} from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import InteractiveImportRow from './InteractiveImportRow'; +import styles from './InteractiveImportModalContent.css'; + +type SelectType = + | 'select' + | 'series' + | 'season' + | 'episode' + | 'releaseGroup' + | 'quality' + | 'language'; + +const COLUMNS = [ + { + name: 'relativePath', + label: 'Relative Path', + isSortable: true, + isVisible: true, + }, + { + name: 'series', + label: 'Series', + isSortable: true, + isVisible: true, + }, + { + name: 'season', + label: 'Season', + isVisible: true, + }, + { + name: 'episodes', + label: 'Episode(s)', + isVisible: true, + }, + { + name: 'releaseGroup', + label: 'Release Group', + isVisible: true, + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true, + }, + { + name: 'languages', + label: 'Languages', + isSortable: true, + isVisible: true, + }, + { + name: 'size', + label: 'Size', + isSortable: true, + isVisible: true, + }, + { + name: 'customFormats', + label: React.createElement(Icon, { + name: icons.INTERACTIVE, + title: 'Custom Format', + }), + isSortable: true, + isVisible: true, + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + kind: kinds.DANGER, + }), + isSortable: true, + isVisible: true, + }, +]; + +const filterExistingFilesOptions = { + ALL: 'all', + NEW: 'new', +}; + +const importModeOptions = [ + { key: 'chooseImportMode', value: 'Choose Import Mode', disabled: true }, + { key: 'move', value: 'Move Files' }, + { key: 'copy', value: 'Hardlink/Copy Files' }, +]; + +function isSameEpisodeFile(file, originalFile) { + const { series, seasonNumber, episodes } = file; + + if (!originalFile) { + return false; + } + + if (!originalFile.series || series.id !== originalFile.series.id) { + return false; + } + + if (seasonNumber !== originalFile.seasonNumber) { + return false; + } + + return !hasDifferentItems(originalFile.episodes, episodes); +} + +const episodeFilesInfoSelector = createSelector( + (state) => state.episodeFiles.isDeleting, + (state) => state.episodeFiles.deleteError, + (isDeleting, deleteError) => { + return { + isDeleting, + deleteError, + }; + } +); + +const importModeSelector = createSelector( + (state) => state.interactiveImport.importMode, + (importMode) => { + return importMode; + } +); + +interface InteractiveImportModalContentProps { + downloadId?: string; + seriesId?: number; + seasonNumber?: number; + showSeries?: boolean; + allowSeriesChange?: boolean; + autoSelectRow?: boolean; + showDelete?: boolean; + showImportMode?: boolean; + showFilterExistingFiles?: boolean; + title?: string; + folder?: string; + sortKey?: string; + sortDirection?: string; + initialSortKey?: string; + initialSortDirection?: string; + modalTitle: string; + onModalClose(): void; +} + +function InteractiveImportModalContent( + props: InteractiveImportModalContentProps +) { + const { + downloadId, + seriesId, + seasonNumber, + allowSeriesChange = true, + autoSelectRow = true, + showSeries = true, + showFilterExistingFiles = false, + showDelete = false, + showImportMode = true, + title, + folder, + initialSortKey, + initialSortDirection, + modalTitle, + onModalClose, + } = props; + + const { + isFetching, + isPopulated, + error, + items, + originalItems, + sortKey, + sortDirection, + } = useSelector(createClientSideCollectionSelector('interactiveImport')); + + const { isDeleting, deleteError } = useSelector(episodeFilesInfoSelector); + const importMode = useSelector(importModeSelector); + + const [invalidRowsSelected, setInvalidRowsSelected] = useState([]); + const [ + withoutEpisodeFileIdRowsSelected, + setWithoutEpisodeFileIdRowsSelected, + ] = useState([]); + const [selectModalOpen, setSelectModalOpen] = useState( + null + ); + const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = + useState(false); + const [filterExistingFiles, setFilterExistingFiles] = useState(false); + const [interactiveImportErrorMessage, setInteractiveImportErrorMessage] = + useState(null); + const [selectState, setSelectState] = useSelectState(); + const [bulkSelectOptions, setBulkSelectOptions] = useState([ + { key: 'select', value: 'Select...', disabled: true }, + { key: 'season', value: 'Select Season' }, + { key: 'episode', value: 'Select Episode(s)' }, + { key: 'quality', value: 'Select Quality' }, + { key: 'releaseGroup', value: 'Select Release Group' }, + { key: 'language', value: 'Select Language' }, + ]); + const { allSelected, allUnselected, selectedState } = selectState; + const previousIsDeleting = usePrevious(isDeleting); + const dispatch = useDispatch(); + + const columns: Column[] = useMemo(() => { + const result = cloneDeep(COLUMNS); + + if (!showSeries) { + result.find((c) => c.name === 'series').isVisible = false; + } + + return result; + }, [showSeries]); + + const selectedIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + useEffect( + () => { + if (allowSeriesChange) { + const newBulkSelectOptions = [...bulkSelectOptions]; + + newBulkSelectOptions.splice(1, 0, { + key: 'series', + value: 'Select Series', + }); + + setBulkSelectOptions(newBulkSelectOptions); + } + + if (initialSortKey) { + const sortProps: { sortKey: string; sortDirection?: string } = { + sortKey: initialSortKey, + }; + + if (initialSortDirection) { + sortProps.sortDirection = initialSortDirection; + } + + dispatch(setInteractiveImportSort(sortProps)); + } + + dispatch( + fetchInteractiveImportItems({ + downloadId, + seriesId, + seasonNumber, + folder, + filterExistingFiles, + }) + ); + + // returned function will be called on component unmount + return () => { + dispatch(clearInteractiveImport()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + if (!isDeleting && previousIsDeleting && !deleteError) { + onModalClose(); + } + }, [previousIsDeleting, isDeleting, deleteError, onModalClose]); + + const onSelectAllChange = useCallback( + ({ value }) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, hasEpisodeFileId, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + + setWithoutEpisodeFileIdRowsSelected( + hasEpisodeFileId || !value + ? without(withoutEpisodeFileIdRowsSelected, id) + : [...withoutEpisodeFileIdRowsSelected, id] + ); + }, + [ + items, + withoutEpisodeFileIdRowsSelected, + setSelectState, + setWithoutEpisodeFileIdRowsSelected, + ] + ); + + const onValidRowChange = useCallback( + (id: number, isValid: boolean) => { + if (isValid && invalidRowsSelected.includes(id)) { + setInvalidRowsSelected(without(invalidRowsSelected, id)); + } else if (!isValid && !invalidRowsSelected.includes(id)) { + setInvalidRowsSelected([...invalidRowsSelected, id]); + } + }, + [invalidRowsSelected, setInvalidRowsSelected] + ); + + const onDeleteSelectedPress = useCallback(() => { + setIsConfirmDeleteModalOpen(true); + }, [setIsConfirmDeleteModalOpen]); + + const onConfirmDelete = useCallback(() => { + setIsConfirmDeleteModalOpen(false); + + const episodeFileIds = items.reduce((acc, item) => { + if (selectedIds.indexOf(item.id) > -1 && item.episodeFileId) { + acc.push(item.episodeFileId); + } + + return acc; + }, []); + + dispatch(deleteEpisodeFiles({ episodeFileIds })); + }, [items, selectedIds, setIsConfirmDeleteModalOpen, dispatch]); + + const onConfirmDeleteModalClose = useCallback(() => { + setIsConfirmDeleteModalOpen(false); + }, [setIsConfirmDeleteModalOpen]); + + const onImportSelectedPress = useCallback(() => { + const finalImportMode = + downloadId || !showImportMode ? ImportMode.Auto : importMode; + + const existingFiles = []; + const files = []; + + if (finalImportMode === 'chooseImportMode') { + setInteractiveImportErrorMessage('An import mode must be selected'); + + return; + } + + items.forEach((item) => { + const isSelected = selectedIds.indexOf(item.id) > -1; + + if (isSelected) { + const { + series, + seasonNumber, + episodes, + releaseGroup, + quality, + languages, + episodeFileId, + } = item; + + if (!series) { + setInteractiveImportErrorMessage( + 'Series must be chosen for each selected file' + ); + return; + } + + if (isNaN(seasonNumber)) { + setInteractiveImportErrorMessage( + 'Season must be chosen for each selected file' + ); + return; + } + + if (!episodes || !episodes.length) { + setInteractiveImportErrorMessage( + 'One or more episodes must be chosen for each selected file' + ); + return; + } + + if (!quality) { + setInteractiveImportErrorMessage( + 'Quality must be chosen for each selected file' + ); + return; + } + + if (!languages) { + setInteractiveImportErrorMessage( + 'Language(s) must be chosen for each selected file' + ); + return; + } + + setInteractiveImportErrorMessage(null); + + if (episodeFileId) { + const originalItem = originalItems.find((i) => i.id === item.id); + + if (isSameEpisodeFile(item, originalItem)) { + existingFiles.push({ + id: episodeFileId, + releaseGroup, + quality, + languages, + }); + + return; + } + } + + files.push({ + path: item.path, + folderName: item.folderName, + seriesId: series.id, + episodeIds: episodes.map((e) => e.id), + releaseGroup, + quality, + languages, + downloadId, + episodeFileId, + }); + } + }); + + let shouldClose = false; + + if (existingFiles.length) { + dispatch( + updateEpisodeFiles({ + files: existingFiles, + }) + ); + + shouldClose = true; + } + + if (files.length) { + dispatch( + executeCommand({ + name: commandNames.INTERACTIVE_IMPORT, + files, + importMode, + }) + ); + + shouldClose = true; + } + + if (shouldClose) { + onModalClose(); + } + }, [ + downloadId, + showImportMode, + importMode, + items, + originalItems, + selectedIds, + onModalClose, + dispatch, + ]); + + const onSortPress = useCallback( + (sortKey, sortDirection) => { + dispatch(setInteractiveImportSort({ sortKey, sortDirection })); + }, + [dispatch] + ); + + const onFilterExistingFilesChange = useCallback( + (value) => { + const filter = value !== filterExistingFilesOptions.ALL; + + setFilterExistingFiles(filter); + + dispatch( + fetchInteractiveImportItems({ + downloadId, + seriesId, + folder, + filterExistingFiles: filter, + }) + ); + }, + [downloadId, seriesId, folder, setFilterExistingFiles, dispatch] + ); + + const onImportModeChange = useCallback( + ({ value }) => { + dispatch(setInteractiveImportMode({ importMode: value })); + }, + [dispatch] + ); + + const onSelectModalSelect = useCallback( + ({ value }) => { + setSelectModalOpen(value); + }, + [setSelectModalOpen] + ); + + const onSelectModalClose = useCallback(() => { + setSelectModalOpen(null); + }, [setSelectModalOpen]); + + const onSeriesSelect = useCallback( + (series) => { + dispatch( + updateInteractiveImportItems({ + ids: selectedIds, + series, + seasonNumber: undefined, + episodes: [], + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + + setSelectModalOpen(null); + }, + [selectedIds, setSelectModalOpen, dispatch] + ); + + const onSeasonSelect = useCallback( + (seasonNumber) => { + dispatch( + updateInteractiveImportItems({ + ids: selectedIds, + seasonNumber, + episodes: [], + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + + setSelectModalOpen(null); + }, + [selectedIds, setSelectModalOpen, dispatch] + ); + + const onEpisodesSelect = useCallback( + (episodes) => { + dispatch( + updateInteractiveImportItems({ + ids: selectedIds, + episodes, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + + setSelectModalOpen(null); + }, + [selectedIds, setSelectModalOpen, dispatch] + ); + + const onReleaseGroupSelect = useCallback( + (releaseGroup) => { + dispatch( + updateInteractiveImportItems({ + ids: selectedIds, + releaseGroup, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + + setSelectModalOpen(null); + }, + [selectedIds, dispatch] + ); + + const onLanguagesSelect = useCallback( + (newLanguages) => { + dispatch( + updateInteractiveImportItems({ + ids: selectedIds, + languages: newLanguages, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + + setSelectModalOpen(null); + }, + [selectedIds, dispatch] + ); + + const onQualitySelect = useCallback( + (quality) => { + dispatch( + updateInteractiveImportItems({ + ids: selectedIds, + quality, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + + setSelectModalOpen(null); + }, + [selectedIds, dispatch] + ); + + const orderedSelectedIds = items.reduce((acc, file) => { + if (selectedIds.includes(file.id)) { + acc.push(file.id); + } + + return acc; + }, []); + + const selectedItem = selectedIds.length + ? items.find((file) => file.id === selectedIds[0]) + : null; + + const errorMessage = getErrorMessage( + error, + 'Unable to load manual import items' + ); + + return ( + + + {modalTitle} - {title || folder} + + + + {showFilterExistingFiles && ( +
+ + + + +
+ {filterExistingFiles ? 'Unmapped Files Only' : 'All Files'} +
+
+ + + + All Files + + + + Unmapped Files Only + + +
+
+ )} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} + + {isPopulated && !items.length && !isFetching + ? 'No video files were found in the selected folder' + : null} +
+ + +
+ {showDelete ? ( + + Delete + + ) : null} + + {!downloadId && showImportMode ? ( + + ) : null} + + +
+ +
+ + + {interactiveImportErrorMessage && ( + + {interactiveImportErrorMessage} + + )} + + +
+
+ + + + + + + + + + + + + + +
+ ); +} + +export default InteractiveImportModalContent; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js deleted file mode 100644 index 360f59652..000000000 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js +++ /dev/null @@ -1,327 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { sortDirections } from 'Helpers/Props'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { deleteEpisodeFiles, updateEpisodeFiles } from 'Store/Actions/episodeFileActions'; -import { clearInteractiveImport, fetchInteractiveImportItems, setInteractiveImportMode, setInteractiveImportSort } from 'Store/Actions/interactiveImportActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import InteractiveImportModalContent from './InteractiveImportModalContent'; - -function isSameEpisodeFile(file, originalFile) { - const { - series, - seasonNumber, - episodes - } = file; - - if (!originalFile) { - return false; - } - - if (!originalFile.series || series.id !== originalFile.series.id) { - return false; - } - - if (seasonNumber !== originalFile.seasonNumber) { - return false; - } - - return !hasDifferentItems(originalFile.episodes, episodes); -} - -function createMapStateToProps() { - return createSelector( - createClientSideCollectionSelector('interactiveImport'), - (state) => state.episodeFiles.isDeleting, - (state) => state.episodeFiles.deleteError, - (interactiveImport, isDeleting, deleteError) => { - return { - ...interactiveImport, - isDeleting, - deleteError - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchInteractiveImportItems: fetchInteractiveImportItems, - dispatchSetInteractiveImportSort: setInteractiveImportSort, - dispatchSetInteractiveImportMode: setInteractiveImportMode, - dispatchClearInteractiveImport: clearInteractiveImport, - dispatchUpdateEpisodeFiles: updateEpisodeFiles, - dispatchDeleteEpisodeFiles: deleteEpisodeFiles, - dispatchExecuteCommand: executeCommand -}; - -class InteractiveImportModalContentConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - interactiveImportErrorMessage: null, - filterExistingFiles: true - }; - } - - componentDidMount() { - const { - downloadId, - seriesId, - seasonNumber, - folder, - initialSortKey, - initialSortDirection, - dispatchSetInteractiveImportSort, - dispatchFetchInteractiveImportItems - } = this.props; - - const { - filterExistingFiles - } = this.state; - - if (initialSortKey) { - const sortProps = { - sortKey: initialSortKey - }; - - if (initialSortDirection) { - sortProps.sortDirection = initialSortDirection; - } - - dispatchSetInteractiveImportSort(sortProps); - } - - dispatchFetchInteractiveImportItems({ - downloadId, - seriesId, - seasonNumber, - folder, - filterExistingFiles - }); - } - - componentDidUpdate(prevProps, prevState) { - const { - filterExistingFiles - } = this.state; - - if (prevState.filterExistingFiles !== filterExistingFiles) { - const { - downloadId, - seriesId, - folder - } = this.props; - - this.props.dispatchFetchInteractiveImportItems({ - downloadId, - seriesId, - folder, - filterExistingFiles - }); - } - } - - componentWillUnmount() { - this.props.dispatchClearInteractiveImport(); - } - - // - // Listeners - - onSortPress = (sortKey, sortDirection) => { - this.props.dispatchSetInteractiveImportSort({ sortKey, sortDirection }); - }; - - onFilterExistingFilesChange = (filterExistingFiles) => { - this.setState({ filterExistingFiles }); - }; - - onImportModeChange = (importMode) => { - this.props.dispatchSetInteractiveImportMode({ importMode }); - }; - - onDeleteSelectedPress = (selected) => { - const { - items, - dispatchDeleteEpisodeFiles - } = this.props; - - const episodeFileIds = items.reduce((acc, item) => { - if (selected.indexOf(item.id) > -1 && item.episodeFileId) { - acc.push(item.episodeFileId); - } - - return acc; - }, []); - - dispatchDeleteEpisodeFiles({ episodeFileIds }); - }; - - onImportSelectedPress = (selected, importMode) => { - const { - items, - originalItems, - dispatchUpdateEpisodeFiles, - dispatchExecuteCommand, - onModalClose - } = this.props; - - const existingFiles = []; - const files = []; - - if (importMode === 'chooseImportMode') { - this.setState({ interactiveImportErrorMessage: 'An import mode must be selected' }); - return; - } - - items.forEach((item) => { - const isSelected = selected.indexOf(item.id) > -1; - - if (isSelected) { - const { - series, - seasonNumber, - episodes, - releaseGroup, - quality, - languages, - episodeFileId - } = item; - - if (!series) { - this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' }); - return; - } - - if (isNaN(seasonNumber)) { - this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' }); - return; - } - - if (!episodes || !episodes.length) { - this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' }); - return; - } - - if (!quality) { - this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' }); - return; - } - - if (!languages) { - this.setState({ interactiveImportErrorMessage: 'Language(s) must be chosen for each selected file' }); - return; - } - - if (episodeFileId) { - const originalItem = originalItems.find((i) => i.id === item.id); - - if (isSameEpisodeFile(item, originalItem)) { - existingFiles.push({ - id: episodeFileId, - releaseGroup, - quality, - languages - }); - - return; - } - } - - files.push({ - path: item.path, - folderName: item.folderName, - seriesId: series.id, - episodeIds: episodes.map((e) => e.id), - releaseGroup, - quality, - languages, - downloadId: this.props.downloadId, - episodeFileId - }); - } - }); - - let shouldClose = false; - - if (existingFiles.length) { - dispatchUpdateEpisodeFiles({ - files: existingFiles - }); - - shouldClose = true; - } - - if (files.length) { - dispatchExecuteCommand({ - name: commandNames.INTERACTIVE_IMPORT, - files, - importMode - }); - - shouldClose = true; - } - - if (shouldClose) { - onModalClose(); - } - }; - - // - // Render - - render() { - const { - interactiveImportErrorMessage, - filterExistingFiles - } = this.state; - - return ( - - ); - } -} - -InteractiveImportModalContentConnector.propTypes = { - downloadId: PropTypes.string, - seriesId: PropTypes.number, - seasonNumber: PropTypes.number, - folder: PropTypes.string, - filterExistingFiles: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - initialSortKey: PropTypes.string, - initialSortDirection: PropTypes.oneOf(sortDirections.all), - originalItems: PropTypes.arrayOf(PropTypes.object).isRequired, - dispatchFetchInteractiveImportItems: PropTypes.func.isRequired, - dispatchSetInteractiveImportSort: PropTypes.func.isRequired, - dispatchSetInteractiveImportMode: PropTypes.func.isRequired, - dispatchClearInteractiveImport: PropTypes.func.isRequired, - dispatchUpdateEpisodeFiles: PropTypes.func.isRequired, - dispatchDeleteEpisodeFiles: PropTypes.func.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -InteractiveImportModalContentConnector.defaultProps = { - filterExistingFiles: true -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js deleted file mode 100644 index ea2b8ede9..000000000 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ /dev/null @@ -1,504 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import TableRow from 'Components/Table/TableRow'; -import Popover from 'Components/Tooltip/Popover'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; -import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; -import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; -import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; -import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; -import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; -import formatBytes from 'Utilities/Number/formatBytes'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; -import styles from './InteractiveImportRow.css'; - -class InteractiveImportRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isSelectSeriesModalOpen: false, - isSelectSeasonModalOpen: false, - isSelectEpisodeModalOpen: false, - isSelectReleaseGroupModalOpen: false, - isSelectQualityModalOpen: false, - isSelectLanguageModalOpen: false - }; - } - - componentDidMount() { - const { - allowSeriesChange, - id, - series, - seasonNumber, - episodes, - quality, - languages, - episodeFileId, - columns - } = this.props; - - if ( - allowSeriesChange && - series && - seasonNumber != null && - episodes.length && - quality && - languages - ) { - this.props.onSelectedChange({ - id, - hasEpisodeFileId: !!episodeFileId, - value: true - }); - } - - this.setState({ - isSeriesColumnVisible: columns.find((c) => c.name === 'series').isVisible - }); - } - - componentDidUpdate(prevProps) { - const { - id, - series, - seasonNumber, - episodes, - quality, - languages, - isSelected, - onValidRowChange - } = this.props; - - if ( - prevProps.series === series && - prevProps.seasonNumber === seasonNumber && - !hasDifferentItems(prevProps.episodes, episodes) && - prevProps.quality === quality && - prevProps.languages === languages && - prevProps.isSelected === isSelected - ) { - return; - } - - const isValid = !!( - series && - seasonNumber != null && - episodes.length && - quality && - languages - ); - - if (isSelected && !isValid) { - onValidRowChange(id, false); - } else { - onValidRowChange(id, true); - } - } - - // - // Control - - selectRowAfterChange = (value) => { - const { - id, - episodeFileId, - isSelected - } = this.props; - - if (!isSelected && value === true) { - this.props.onSelectedChange({ - id, - hasEpisodeFileId: !!episodeFileId, - value - }); - } - }; - - // - // Listeners - - onSelectedChange = (result) => { - const { - episodeFileId, - onSelectedChange - } = this.props; - - onSelectedChange({ - ...result, - hasEpisodeFileId: !!episodeFileId - }); - }; - - onSelectSeriesPress = () => { - this.setState({ isSelectSeriesModalOpen: true }); - }; - - onSelectSeasonPress = () => { - this.setState({ isSelectSeasonModalOpen: true }); - }; - - onSelectEpisodePress = () => { - this.setState({ isSelectEpisodeModalOpen: true }); - }; - - onSelectReleaseGroupPress = () => { - this.setState({ isSelectReleaseGroupModalOpen: true }); - }; - - onSelectQualityPress = () => { - this.setState({ isSelectQualityModalOpen: true }); - }; - - onSelectLanguagePress = () => { - this.setState({ isSelectLanguageModalOpen: true }); - }; - - onSelectSeriesModalClose = (changed) => { - this.setState({ isSelectSeriesModalOpen: false }); - this.selectRowAfterChange(changed); - }; - - onSelectSeasonModalClose = (changed) => { - this.setState({ isSelectSeasonModalOpen: false }); - this.selectRowAfterChange(changed); - }; - - onSelectEpisodeModalClose = (changed) => { - this.setState({ isSelectEpisodeModalOpen: false }); - this.selectRowAfterChange(changed); - }; - - onSelectReleaseGroupModalClose = (changed) => { - this.setState({ isSelectReleaseGroupModalOpen: false }); - this.selectRowAfterChange(changed); - }; - - onSelectQualityModalClose = (changed) => { - this.setState({ isSelectQualityModalOpen: false }); - this.selectRowAfterChange(changed); - }; - - onSelectLanguageModalClose = (changed) => { - this.setState({ isSelectLanguageModalOpen: false }); - this.selectRowAfterChange(changed); - }; - - // - // Render - - render() { - const { - id, - allowSeriesChange, - relativePath, - series, - seasonNumber, - episodes, - quality, - languages, - releaseGroup, - size, - customFormats, - rejections, - isReprocessing, - isSelected, - modalTitle - } = this.props; - - const { - isSelectSeriesModalOpen, - isSelectSeasonModalOpen, - isSelectEpisodeModalOpen, - isSelectReleaseGroupModalOpen, - isSelectQualityModalOpen, - isSelectLanguageModalOpen - } = this.state; - - const seriesTitle = series ? series.title : ''; - const isAnime = series ? series.seriesType === 'anime' : false; - - const episodeInfo = episodes.map((episode) => { - return ( -
- {episode.episodeNumber} - - { - isAnime && episode.absoluteEpisodeNumber != null ? - ` (${episode.absoluteEpisodeNumber})` : - '' - } - - {` - ${episode.title}`} -
- ); - }); - - const showSeriesPlaceholder = isSelected && !series; - const showSeasonNumberPlaceholder = isSelected && !!series && isNaN(seasonNumber) && !isReprocessing; - const showEpisodeNumbersPlaceholder = isSelected && Number.isInteger(seasonNumber) && !episodes.length; - const showReleaseGroupPlaceholder = isSelected && !releaseGroup; - const showQualityPlaceholder = isSelected && !quality; - const showLanguagePlaceholder = isSelected && !languages; - - return ( - - - - - {relativePath} - - - { - this.state.isSeriesColumnVisible ? - - { - showSeriesPlaceholder ? : seriesTitle - } - : - null - } - - - { - showSeasonNumberPlaceholder ? : seasonNumber - } - - { - isReprocessing && seasonNumber == null ? - : null - } - - - - { - showEpisodeNumbersPlaceholder ? : episodeInfo - } - - - - { - showReleaseGroupPlaceholder ? - : - releaseGroup - } - - - - { - showQualityPlaceholder && - - } - - { - !showQualityPlaceholder && !!quality && - - } - - - - { - showLanguagePlaceholder && - - } - - { - !showLanguagePlaceholder && !!languages && - - } - - - - {formatBytes(size)} - - - - { - customFormats?.length ? - - } - title="Formats" - body={ -
- -
- } - position={tooltipPositions.LEFT} - /> : - null - } -
- - - { - rejections.length ? - - } - title="Release Rejected" - body={ -
    - { - rejections.map((rejection, index) => { - return ( -
  • - {rejection.reason} -
  • - ); - }) - } -
- } - position={tooltipPositions.LEFT} - canFlip={false} - /> : - null - } -
- - - - - - - - - - 1 : false} - real={quality ? quality.revision.real > 0 : false} - modalTitle={modalTitle} - onModalClose={this.onSelectQualityModalClose} - /> - - l.id) : []} - modalTitle={modalTitle} - onModalClose={this.onSelectLanguageModalClose} - /> -
- ); - } - -} - -InteractiveImportRow.propTypes = { - id: PropTypes.number.isRequired, - allowSeriesChange: PropTypes.bool.isRequired, - relativePath: PropTypes.string.isRequired, - series: PropTypes.object, - seasonNumber: PropTypes.number, - episodes: PropTypes.arrayOf(PropTypes.object).isRequired, - releaseGroup: PropTypes.string, - quality: PropTypes.object, - languages: PropTypes.arrayOf(PropTypes.object), - size: PropTypes.number.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - rejections: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - episodeFileId: PropTypes.number, - isReprocessing: PropTypes.bool, - isSelected: PropTypes.bool, - modalTitle: PropTypes.string.isRequired, - onSelectedChange: PropTypes.func.isRequired, - onValidRowChange: PropTypes.func.isRequired -}; - -InteractiveImportRow.defaultProps = { - episodes: [] -}; - -export default InteractiveImportRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx new file mode 100644 index 000000000..6184af2d8 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx @@ -0,0 +1,506 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import Popover from 'Components/Tooltip/Popover'; +import Episode from 'Episode/Episode'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; +import { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent'; +import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; +import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import Series from 'Series/Series'; +import { + reprocessInteractiveImportItems, + updateInteractiveImportItem, +} from 'Store/Actions/interactiveImportActions'; +import Rejection from 'typings/Rejection'; +import formatBytes from 'Utilities/Number/formatBytes'; +import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; +import styles from './InteractiveImportRow.css'; + +type SelectType = + | 'series' + | 'season' + | 'episode' + | 'releaseGroup' + | 'quality' + | 'language'; + +interface InteractiveImportRowProps { + id: number; + allowSeriesChange: boolean; + relativePath: string; + series?: Series; + seasonNumber?: number; + episodes?: Episode[]; + releaseGroup?: string; + quality?: QualityModel; + languages?: Language[]; + size: number; + customFormats?: object[]; + rejections: Rejection[]; + columns: Column[]; + episodeFileId?: number; + isReprocessing?: boolean; + isSelected?: boolean; + modalTitle: string; + onSelectedChange(...args: unknown[]): void; + onValidRowChange(id: number, isValid: boolean): void; +} + +function InteractiveImportRow(props: InteractiveImportRowProps) { + const { + id, + allowSeriesChange, + relativePath, + series, + seasonNumber, + episodes = [], + quality, + languages, + releaseGroup, + size, + customFormats, + rejections, + isReprocessing, + isSelected, + modalTitle, + episodeFileId, + columns, + onSelectedChange, + onValidRowChange, + } = props; + + const dispatch = useDispatch(); + + const isSeriesColumnVisible = useMemo( + () => columns.find((c) => c.name === 'series').isVisible, + [columns] + ); + + const [selectModalOpen, setSelectModalOpen] = useState( + null + ); + + useEffect( + () => { + if ( + allowSeriesChange && + series && + seasonNumber != null && + episodes.length && + quality && + languages + ) { + onSelectedChange({ + id, + hasEpisodeFileId: !!episodeFileId, + value: true, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + const isValid = !!( + series && + seasonNumber != null && + episodes.length && + quality && + languages + ); + + if (isSelected && !isValid) { + onValidRowChange(id, false); + } else { + onValidRowChange(id, true); + } + }, [ + id, + series, + seasonNumber, + episodes, + quality, + languages, + isSelected, + onValidRowChange, + ]); + + const onSelectedChangeWrapper = useCallback( + (result) => { + onSelectedChange({ + ...result, + hasEpisodeFileId: !!episodeFileId, + }); + }, + [episodeFileId, onSelectedChange] + ); + + const selectRowAfterChange = useCallback(() => { + if (!isSelected) { + onSelectedChange({ + id, + hasEpisodeFileId: !!episodeFileId, + value: true, + }); + } + }, [id, episodeFileId, isSelected, onSelectedChange]); + + const onSelectModalClose = useCallback(() => { + setSelectModalOpen(null); + }, [setSelectModalOpen]); + + const onSelectSeriesPress = useCallback(() => { + setSelectModalOpen('series'); + }, [setSelectModalOpen]); + + const onSeriesSelect = useCallback( + (series: Series) => { + dispatch( + updateInteractiveImportItem({ + id, + series, + seasonNumber: undefined, + episodes: [], + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: [id] })); + + setSelectModalOpen(null); + selectRowAfterChange(); + }, + [id, dispatch, setSelectModalOpen, selectRowAfterChange] + ); + + const onSelectSeasonPress = useCallback(() => { + setSelectModalOpen('season'); + }, [setSelectModalOpen]); + + const onSeasonSelect = useCallback( + (seasonNumber: number) => { + dispatch( + updateInteractiveImportItem({ + id, + seasonNumber, + episodes: [], + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: [id] })); + + setSelectModalOpen(null); + selectRowAfterChange(); + }, + [id, dispatch, setSelectModalOpen, selectRowAfterChange] + ); + + const onSelectEpisodePress = useCallback(() => { + setSelectModalOpen('episode'); + }, [setSelectModalOpen]); + + const onEpisodesSelect = useCallback( + (selectedEpisodes: SelectedEpisode[]) => { + dispatch( + updateInteractiveImportItem({ + id, + episodes: selectedEpisodes[0].episodes, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: [id] })); + + setSelectModalOpen(null); + selectRowAfterChange(); + }, + [id, dispatch, setSelectModalOpen, selectRowAfterChange] + ); + + const onSelectReleaseGroupPress = useCallback(() => { + setSelectModalOpen('releaseGroup'); + }, [setSelectModalOpen]); + + const onReleaseGroupSelect = useCallback( + (releaseGroup: string) => { + dispatch( + updateInteractiveImportItem({ + id, + releaseGroup, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: [id] })); + + setSelectModalOpen(null); + selectRowAfterChange(); + }, + [id, dispatch, setSelectModalOpen, selectRowAfterChange] + ); + + const onSelectQualityPress = useCallback(() => { + setSelectModalOpen('quality'); + }, [setSelectModalOpen]); + + const onQualitySelect = useCallback( + (quality: QualityModel) => { + dispatch( + updateInteractiveImportItem({ + id, + quality, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: [id] })); + + setSelectModalOpen(null); + selectRowAfterChange(); + }, + [id, dispatch, setSelectModalOpen, selectRowAfterChange] + ); + + const onSelectLanguagePress = useCallback(() => { + setSelectModalOpen('language'); + }, [setSelectModalOpen]); + + const onLanguagesSelect = useCallback( + (languages: Language[]) => { + dispatch( + updateInteractiveImportItem({ + id, + languages, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: [id] })); + + setSelectModalOpen(null); + selectRowAfterChange(); + }, + [id, dispatch, setSelectModalOpen, selectRowAfterChange] + ); + + const seriesTitle = series ? series.title : ''; + const isAnime = series?.seriesType === 'anime'; + + const episodeInfo = episodes.map((episode) => { + return ( +
+ {episode.episodeNumber} + + {isAnime && episode.absoluteEpisodeNumber != null + ? ` (${episode.absoluteEpisodeNumber})` + : ''} + + {` - ${episode.title}`} +
+ ); + }); + + const showSeriesPlaceholder = isSelected && !series; + const showSeasonNumberPlaceholder = + isSelected && !!series && isNaN(seasonNumber) && !isReprocessing; + const showEpisodeNumbersPlaceholder = + isSelected && Number.isInteger(seasonNumber) && !episodes.length; + const showReleaseGroupPlaceholder = isSelected && !releaseGroup; + const showQualityPlaceholder = isSelected && !quality; + const showLanguagePlaceholder = isSelected && !languages; + + return ( + + + + + {relativePath} + + + {isSeriesColumnVisible ? ( + + {showSeriesPlaceholder ? ( + + ) : ( + seriesTitle + )} + + ) : null} + + + {showSeasonNumberPlaceholder ? ( + + ) : ( + seasonNumber + )} + + {isReprocessing && seasonNumber == null ? ( + + ) : null} + + + + {showEpisodeNumbersPlaceholder ? ( + + ) : ( + episodeInfo + )} + + + + {showReleaseGroupPlaceholder ? ( + + ) : ( + releaseGroup + )} + + + + {showQualityPlaceholder && } + + {!showQualityPlaceholder && !!quality && ( + + )} + + + + {showLanguagePlaceholder && } + + {!showLanguagePlaceholder && !!languages && ( + + )} + + + {formatBytes(size)} + + + {customFormats?.length ? ( + } + title="Formats" + body={ +
+ +
+ } + position={tooltipPositions.LEFT} + /> + ) : null} +
+ + + {rejections.length ? ( + } + title="Release Rejected" + body={ +
    + {rejections.map((rejection, index) => { + return
  • {rejection.reason}
  • ; + })} +
+ } + position={tooltipPositions.LEFT} + canFlip={false} + /> + ) : null} +
+ + + + + + + + + + 1 : false} + real={quality ? quality.revision.real > 0 : false} + modalTitle={modalTitle} + onQualitySelect={onQualitySelect} + onModalClose={onSelectModalClose} + /> + + l.id) : []} + modalTitle={modalTitle} + onLanguagesSelect={onLanguagesSelect} + onModalClose={onSelectModalClose} + /> +
+ ); +} + +export default InteractiveImportRow; diff --git a/frontend/src/InteractiveImport/InteractiveImport.ts b/frontend/src/InteractiveImport/InteractiveImport.ts new file mode 100644 index 000000000..f9a686b4e --- /dev/null +++ b/frontend/src/InteractiveImport/InteractiveImport.ts @@ -0,0 +1,25 @@ +import ModelBase from 'App/ModelBase'; +import Episode from 'Episode/Episode'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import Series from 'Series/Series'; + +interface InteractiveImport extends ModelBase { + path: string; + relativePath: string; + folderName: string; + name: string; + size: number; + releaseGroup: string; + quality: QualityModel; + languages: Language[]; + series?: Series; + seasonNumber: number; + episodes: Episode[]; + qualityWeight: number; + customFormats: object[]; + rejections: string[]; + episodeFileId?: number; +} + +export default InteractiveImport; diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js deleted file mode 100644 index dda75152f..000000000 --- a/frontend/src/InteractiveImport/InteractiveImportModal.js +++ /dev/null @@ -1,86 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import { sizes } from 'Helpers/Props'; -import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector'; -import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector'; - -class InteractiveImportModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - folder: null - }; - } - - componentDidUpdate(prevProps) { - if (prevProps.isOpen && !this.props.isOpen) { - this.setState({ folder: null }); - } - } - - // - // Listeners - - onFolderSelect = (folder) => { - this.setState({ folder }); - }; - - // - // Render - - render() { - const { - isOpen, - folder, - downloadId, - onModalClose, - ...otherProps - } = this.props; - - const folderPath = folder || this.state.folder; - - return ( - - { - folderPath || downloadId ? - : - - } - - ); - } -} - -InteractiveImportModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - folder: PropTypes.string, - downloadId: PropTypes.string, - modalTitle: PropTypes.string.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -InteractiveImportModal.defaultProps = { - modalTitle: 'Manual Import' -}; - -export default InteractiveImportModal; diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.tsx b/frontend/src/InteractiveImport/InteractiveImportModal.tsx new file mode 100644 index 000000000..8c25ec3e5 --- /dev/null +++ b/frontend/src/InteractiveImport/InteractiveImportModal.tsx @@ -0,0 +1,73 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import Modal from 'Components/Modal/Modal'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { sizes } from 'Helpers/Props'; +import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent'; +import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent'; + +interface InteractiveImportModalProps { + isOpen: boolean; + folder?: string; + downloadId?: string; + modalTitle?: string; + onModalClose(): void; +} + +function InteractiveImportModal(props: InteractiveImportModalProps) { + const { + isOpen, + folder, + downloadId, + modalTitle = 'Manual Import', + onModalClose, + ...otherProps + } = props; + + const [folderPath, setFolderPath] = useState(folder); + const previousIsOpen = usePrevious(isOpen); + + const onFolderSelect = useCallback( + (f) => { + setFolderPath(f); + }, + [setFolderPath] + ); + + useEffect(() => { + setFolderPath(folder); + }, [folder, setFolderPath]); + + useEffect(() => { + if (previousIsOpen && !isOpen) { + setFolderPath(folder); + } + }, [folder, previousIsOpen, isOpen, setFolderPath]); + + return ( + + {folderPath || downloadId ? ( + + ) : ( + + )} + + ); +} + +export default InteractiveImportModal; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModal.js b/frontend/src/InteractiveImport/Language/SelectLanguageModal.js deleted file mode 100644 index f0164358e..000000000 --- a/frontend/src/InteractiveImport/Language/SelectLanguageModal.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import { sizes } from 'Helpers/Props'; -import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector'; - -class SelectLanguageModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -SelectLanguageModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectLanguageModal; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModal.tsx b/frontend/src/InteractiveImport/Language/SelectLanguageModal.tsx new file mode 100644 index 000000000..dbde852f2 --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModal.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import Language from 'Language/Language'; +import SelectLanguageModalContent from './SelectLanguageModalContent'; + +interface SelectLanguageModalProps { + isOpen: boolean; + languageIds: number[]; + modalTitle: string; + onLanguagesSelect(languages: Language[]): void; + onModalClose(): void; +} + +function SelectLanguageModal(props: SelectLanguageModalProps) { + const { isOpen, languageIds, modalTitle, onLanguagesSelect, onModalClose } = + props; + + return ( + + + + ); +} + +export default SelectLanguageModal; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js deleted file mode 100644 index 32c4a1c87..000000000 --- a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js +++ /dev/null @@ -1,154 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import styles from './SelectLanguageModalContent.css'; - -class SelectLanguageModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - languageIds - } = props; - - this.state = { - languageIds - }; - } - - // - // Listeners - - onLanguageChange = ({ value, name }) => { - const { - languageIds - } = this.state; - - const changedId = parseInt(name); - - let newLanguages = languageIds; - - if (value) { - newLanguages.push(changedId); - } - - if (!value) { - newLanguages = languageIds.filter((i) => i !== changedId); - } - - this.setState({ languageIds: newLanguages }); - }; - - onLanguageSelect = () => { - this.props.onLanguageSelect(this.state); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - modalTitle, - onModalClose - } = this.props; - - const { - languageIds - } = this.state; - - return ( - - - {modalTitle} - - - - { - isFetching && - - } - - { - !isFetching && !!error && -
- Unable To Load Languages -
- } - - { - isPopulated && !error && -
- { - items.map(( language ) => { - return ( - - {language.name} - - - ); - }) - } -
- } -
- - - - - - -
- ); - } -} - -SelectLanguageModalContent.propTypes = { - languageIds: PropTypes.arrayOf(PropTypes.number).isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - modalTitle: PropTypes.string.isRequired, - onLanguageSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -SelectLanguageModalContent.defaultProps = { - languages: [] -}; - -export default SelectLanguageModalContent; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx new file mode 100644 index 000000000..a4231e101 --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Language from 'Language/Language'; +import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; +import styles from './SelectLanguageModalContent.css'; + +interface SelectLanguageModalContentProps { + languageIds: number[]; + modalTitle: string; + onLanguagesSelect(languages: Language[]): void; + onModalClose(): void; +} + +function createFilteredLanguagesSelector() { + return createSelector(createLanguagesSelector(), (languages) => { + const { isFetching, isPopulated, error, items } = languages; + + const filterItems = ['Any', 'Original']; + const filteredLanguages = items.filter( + (lang) => !filterItems.includes(lang.name) + ); + + return { + isFetching, + isPopulated, + error, + items: filteredLanguages, + }; + }); +} + +function SelectLanguageModalContent(props: SelectLanguageModalContentProps) { + const { modalTitle, onLanguagesSelect, onModalClose } = props; + + const { isFetching, isPopulated, error, items } = useSelector( + createFilteredLanguagesSelector() + ); + + const [languageIds, setLanguageIds] = useState(props.languageIds); + + const onLanguageChange = useCallback( + ({ value, name }) => { + const changedId = parseInt(name); + + let newLanguages = [...languageIds]; + + if (value) { + newLanguages.push(changedId); + } else { + newLanguages = languageIds.filter((i) => i !== changedId); + } + + setLanguageIds(newLanguages); + }, + [languageIds, setLanguageIds] + ); + + const onLanguagesSelectWrapper = useCallback(() => { + const languages = items.filter((lang) => languageIds.includes(lang.id)); + + onLanguagesSelect(languages); + }, [items, languageIds, onLanguagesSelect]); + + return ( + + {modalTitle} - Select Language + + + {isFetching ? : null} + + {!isFetching && error ?
Unable To Load Languages
: null} + + {isPopulated && !error ? ( +
+ {items.map((language) => { + return ( + + {language.name} + + + ); + })} +
+ ) : null} +
+ + + + + + +
+ ); +} + +export default SelectLanguageModalContent; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js deleted file mode 100644 index f4b6796a3..000000000 --- a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js +++ /dev/null @@ -1,96 +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 { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; -import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; -import SelectLanguageModalContent from './SelectLanguageModalContent'; - -function createMapStateToProps() { - return createSelector( - createLanguagesSelector(), - (languages) => { - const { - isFetching, - isPopulated, - error, - items - } = languages; - - const filterItems = ['Any', 'Original']; - const filteredLanguages = items.filter((lang) => !filterItems.includes(lang.name)); - - return { - isFetching, - isPopulated, - error, - items: filteredLanguages - }; - } - ); -} - -const mapDispatchToProps = { - dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, - dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems -}; - -class SelectLanguageModalContentConnector extends Component { - - // - // Listeners - - onLanguageSelect = ({ languageIds }) => { - const { - ids, - dispatchUpdateInteractiveImportItems, - dispatchReprocessInteractiveImportItems - } = this.props; - - const languages = []; - - languageIds.forEach((languageId) => { - const language = _.find(this.props.items, - (item) => item.id === parseInt(languageId)); - - if (language !== undefined) { - languages.push(language); - } - }); - - dispatchUpdateInteractiveImportItems({ - ids, - languages - }); - - dispatchReprocessInteractiveImportItems({ ids }); - - this.props.onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -SelectLanguageModalContentConnector.propTypes = { - ids: PropTypes.arrayOf(PropTypes.number).isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, - dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector); diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js deleted file mode 100644 index d3e31d2dd..000000000 --- a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import SelectQualityModalContentConnector from './SelectQualityModalContentConnector'; - -class SelectQualityModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -SelectQualityModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectQualityModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.tsx b/frontend/src/InteractiveImport/Quality/SelectQualityModal.tsx new file mode 100644 index 000000000..89401142f --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModal.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { QualityModel } from 'Quality/Quality'; +import SelectQualityModalContent from './SelectQualityModalContent'; + +interface SelectQualityModalProps { + isOpen: boolean; + qualityId: number; + proper: boolean; + real: boolean; + modalTitle: string; + onQualitySelect(quality: QualityModel): void; + onModalClose(): void; +} + +function SelectQualityModal(props: SelectQualityModalProps) { + const { + isOpen, + qualityId, + proper, + real, + modalTitle, + onQualitySelect, + onModalClose, + } = props; + + return ( + + + + ); +} + +export default SelectQualityModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js deleted file mode 100644 index cb89a7131..000000000 --- a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js +++ /dev/null @@ -1,168 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds } from 'Helpers/Props'; - -class SelectQualityModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - qualityId, - proper, - real - } = props; - - this.state = { - qualityId, - proper, - real - }; - } - - // - // Listeners - - onQualityChange = ({ value }) => { - this.setState({ qualityId: parseInt(value) }); - }; - - onProperChange = ({ value }) => { - this.setState({ proper: value }); - }; - - onRealChange = ({ value }) => { - this.setState({ real: value }); - }; - - onQualitySelect = () => { - this.props.onQualitySelect(this.state); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - modalTitle, - onModalClose - } = this.props; - - const { - qualityId, - proper, - real - } = this.state; - - const qualityOptions = items.map(({ id, name }) => { - return { - key: id, - value: name - }; - }); - - return ( - - - {modalTitle} - Select Quality - - - - { - isFetching && - - } - - { - !isFetching && !!error && -
Unable to load qualities
- } - - { - isPopulated && !error && -
- - Quality - - - - - - Proper - - - - - - Real - - - -
- } -
- - - - - - -
- ); - } -} - -SelectQualityModalContent.propTypes = { - qualityId: PropTypes.number.isRequired, - proper: PropTypes.bool.isRequired, - real: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - modalTitle: PropTypes.string.isRequired, - onQualitySelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectQualityModalContent; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.tsx b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.tsx new file mode 100644 index 000000000..0ddc4af5c --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { QualityModel } from 'Quality/Quality'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import getQualities from 'Utilities/Quality/getQualities'; + +function createQualitySchemeSelctor() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { isSchemaFetching, isSchemaPopulated, schemaError, schema } = + qualityProfiles; + + return { + isFetching: isSchemaFetching, + isPopulated: isSchemaPopulated, + error: schemaError, + items: getQualities(schema.items), + }; + } + ); +} + +interface SelectQualityModalContentProps { + qualityId: number; + proper: boolean; + real: boolean; + modalTitle: string; + onQualitySelect(quality: QualityModel): void; + onModalClose(): void; +} + +function SelectQualityModalContent(props: SelectQualityModalContentProps) { + const { modalTitle, onQualitySelect, onModalClose } = props; + + const [qualityId, setQualityId] = useState(props.qualityId); + const [proper, setProper] = useState(props.proper); + const [real, setReal] = useState(props.real); + + const { isFetching, isPopulated, error, items } = useSelector( + createQualitySchemeSelctor() + ); + const dispatch = useDispatch(); + + useEffect( + () => { + dispatch(fetchQualityProfileSchema()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const qualityOptions = useMemo(() => { + return items.map(({ id, name }) => { + return { + key: id, + value: name, + }; + }); + }, [items]); + + const onQualityChange = useCallback( + ({ value }) => { + setQualityId(parseInt(value)); + }, + [setQualityId] + ); + + const onProperChange = useCallback( + ({ value }) => { + setProper(value); + }, + [setProper] + ); + + const onRealChange = useCallback( + ({ value }) => { + setReal(value); + }, + [setReal] + ); + + const onQualitySelectWrapper = useCallback(() => { + const quality = items.find((item) => item.id === qualityId); + + const revision = { + version: proper ? 2 : 1, + real: real ? 1 : 0, + isRepack: false, + }; + + onQualitySelect({ + quality, + revision, + }); + }, [items, qualityId, proper, real, onQualitySelect]); + + return ( + + {modalTitle} - Select Quality + + + {isFetching && } + + {!isFetching && error ?
Unable to load qualities
: null} + + {isPopulated && !error ? ( +
+ + Quality + + + + + + Proper + + + + + + Real + + + +
+ ) : null} +
+ + + + + + +
+ ); +} + +export default SelectQualityModalContent; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js deleted file mode 100644 index 1626b47cf..000000000 --- a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js +++ /dev/null @@ -1,105 +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 { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; -import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; -import getQualities from 'Utilities/Quality/getQualities'; -import SelectQualityModalContent from './SelectQualityModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.qualityProfiles, - (qualityProfiles) => { - const { - isSchemaFetching: isFetching, - isSchemaPopulated: isPopulated, - schemaError: error, - schema - } = qualityProfiles; - - return { - isFetching, - isPopulated, - error, - items: getQualities(schema.items) - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchQualityProfileSchema: fetchQualityProfileSchema, - dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, - dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems -}; - -class SelectQualityModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount = () => { - if (!this.props.isPopulated) { - this.props.dispatchFetchQualityProfileSchema(); - } - }; - - // - // Listeners - - onQualitySelect = ({ qualityId, proper, real }) => { - const { - ids, - dispatchUpdateInteractiveImportItems, - dispatchReprocessInteractiveImportItems - } = this.props; - - const quality = _.find(this.props.items, - (item) => item.id === qualityId); - - const revision = { - version: proper ? 2 : 1, - real: real ? 1 : 0 - }; - - dispatchUpdateInteractiveImportItems({ - ids, - quality: { - quality, - revision - } - }); - - dispatchReprocessInteractiveImportItems({ ids }); - - this.props.onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -SelectQualityModalContentConnector.propTypes = { - ids: PropTypes.arrayOf(PropTypes.number).isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, - dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, - dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector); diff --git a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.js b/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.js deleted file mode 100644 index 04f6e6af3..000000000 --- a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import SelectReleaseGroupModalContentConnector from './SelectReleaseGroupModalContentConnector'; - -class SelectReleaseGroupModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -SelectReleaseGroupModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectReleaseGroupModal; diff --git a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.tsx b/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.tsx new file mode 100644 index 000000000..175f84fd5 --- /dev/null +++ b/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModal.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectReleaseGroupModalContent from './SelectReleaseGroupModalContent'; + +interface SelectReleaseGroupModalProps { + isOpen: boolean; + releaseGroup: string; + modalTitle: string; + onReleaseGroupSelect(releaseGroup: string): void; + onModalClose(): void; +} + +function SelectReleaseGroupModal(props: SelectReleaseGroupModalProps) { + const { + isOpen, + releaseGroup, + modalTitle, + onReleaseGroupSelect, + onModalClose, + } = props; + + return ( + + + + ); +} + +export default SelectReleaseGroupModal; diff --git a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.js b/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.js deleted file mode 100644 index 48a5c0794..000000000 --- a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.js +++ /dev/null @@ -1,105 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds, scrollDirections } from 'Helpers/Props'; -import styles from './SelectReleaseGroupModalContent.css'; - -class SelectReleaseGroupModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - releaseGroup - } = props; - - this.state = { - releaseGroup - }; - } - - // - // Listeners - - onReleaseGroupChange = ({ value }) => { - this.setState({ releaseGroup: value }); - }; - - onReleaseGroupSelect = () => { - this.props.onReleaseGroupSelect(this.state); - }; - - // - // Render - - render() { - const { - modalTitle, - onModalClose - } = this.props; - - const { - releaseGroup - } = this.state; - - return ( - - - {modalTitle} - Set Release Group - - - -
- - Release Group - - - -
-
- - - - - - -
- ); - } -} - -SelectReleaseGroupModalContent.propTypes = { - releaseGroup: PropTypes.string.isRequired, - modalTitle: PropTypes.string.isRequired, - onReleaseGroupSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectReleaseGroupModalContent; diff --git a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.tsx b/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.tsx new file mode 100644 index 000000000..708433e96 --- /dev/null +++ b/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContent.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useState } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, scrollDirections } from 'Helpers/Props'; +import styles from './SelectReleaseGroupModalContent.css'; + +interface SelectReleaseGroupModalContentProps { + releaseGroup: string; + modalTitle: string; + onReleaseGroupSelect(releaseGroup: string): void; + onModalClose(): void; +} + +function SelectReleaseGroupModalContent( + props: SelectReleaseGroupModalContentProps +) { + const { modalTitle, onReleaseGroupSelect, onModalClose } = props; + const [releaseGroup, setReleaseGroup] = useState(props.releaseGroup); + + const onReleaseGroupChange = useCallback( + ({ value }) => { + setReleaseGroup(value); + }, + [setReleaseGroup] + ); + + const onReleaseGroupSelectWrapper = useCallback(() => { + onReleaseGroupSelect(releaseGroup); + }, [releaseGroup, onReleaseGroupSelect]); + + return ( + + {modalTitle} - Set Release Group + + +
+ + Release Group + + + +
+
+ + + + + + +
+ ); +} + +export default SelectReleaseGroupModalContent; diff --git a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContentConnector.js b/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContentConnector.js deleted file mode 100644 index 84e4a8e28..000000000 --- a/frontend/src/InteractiveImport/ReleaseGroup/SelectReleaseGroupModalContentConnector.js +++ /dev/null @@ -1,54 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; -import SelectReleaseGroupModalContent from './SelectReleaseGroupModalContent'; - -const mapDispatchToProps = { - dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, - dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems -}; - -class SelectReleaseGroupModalContentConnector extends Component { - - // - // Listeners - - onReleaseGroupSelect = ({ releaseGroup }) => { - const { - ids, - dispatchUpdateInteractiveImportItems, - dispatchReprocessInteractiveImportItems - } = this.props; - - dispatchUpdateInteractiveImportItems({ - ids, - releaseGroup - }); - - dispatchReprocessInteractiveImportItems({ ids }); - - this.props.onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -SelectReleaseGroupModalContentConnector.propTypes = { - ids: PropTypes.arrayOf(PropTypes.number).isRequired, - dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, - dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(null, mapDispatchToProps)(SelectReleaseGroupModalContentConnector); diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModal.js b/frontend/src/InteractiveImport/Season/SelectSeasonModal.js deleted file mode 100644 index 9de9ee493..000000000 --- a/frontend/src/InteractiveImport/Season/SelectSeasonModal.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import SelectSeasonModalContentConnector from './SelectSeasonModalContentConnector'; - -class SelectSeasonModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -SelectSeasonModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectSeasonModal; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModal.tsx b/frontend/src/InteractiveImport/Season/SelectSeasonModal.tsx new file mode 100644 index 000000000..5132fd4ec --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectSeasonModalContent from './SelectSeasonModalContent'; + +interface SelectSeasonModalProps { + isOpen: boolean; + modalTitle: string; + seriesId: number; + onSeasonSelect(seasonNumber): void; + onModalClose(): void; +} + +function SelectSeasonModal(props: SelectSeasonModalProps) { + const { isOpen, modalTitle, seriesId, onSeasonSelect, onModalClose } = props; + + return ( + + + + ); +} + +export default SelectSeasonModal; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js deleted file mode 100644 index a44ca2a46..000000000 --- a/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import SelectSeasonRow from './SelectSeasonRow'; - -class SelectSeasonModalContent extends Component { - - // - // Render - - render() { - const { - items, - modalTitle, - onSeasonSelect, - onModalClose - } = this.props; - - return ( - - - {modalTitle} - Select Season - - - - { - items.map((item) => { - return ( - - ); - }) - } - - - - - - - ); - } -} - -SelectSeasonModalContent.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - modalTitle: PropTypes.string.isRequired, - onSeasonSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectSeasonModalContent; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.tsx b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.tsx new file mode 100644 index 000000000..41ba30794 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.tsx @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import SelectSeasonRow from './SelectSeasonRow'; + +interface SelectSeasonModalContentProps { + seriesId: number; + modalTitle: string; + onSeasonSelect(seasonNumber): void; + onModalClose(): void; +} + +function SelectSeasonModalContent(props: SelectSeasonModalContentProps) { + const { seriesId, modalTitle, onSeasonSelect, onModalClose } = props; + const series = useSelector(createSeriesSelector(seriesId)); + const seasons = useMemo(() => { + return series.seasons.slice(0).reverse(); + }, [series]); + + return ( + + {modalTitle} - Select Season + + + {seasons.map((item) => { + return ( + + ); + })} + + + + + + + ); +} + +export default SelectSeasonModalContent; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js deleted file mode 100644 index a4c6e0d0e..000000000 --- a/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js +++ /dev/null @@ -1,68 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import SelectSeasonModalContent from './SelectSeasonModalContent'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - (series) => { - if (!series) { - return { - items: [] - }; - } - - return { - items: series.seasons.slice(0).reverse() - }; - } - ); -} - -const mapDispatchToProps = { - updateInteractiveImportItem -}; - -class SelectSeasonModalContentConnector extends Component { - - // - // Listeners - - onSeasonSelect = (seasonNumber) => { - this.props.ids.forEach((id) => { - this.props.updateInteractiveImportItem({ - id, - seasonNumber, - episodes: [] - }); - }); - - this.props.onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -SelectSeasonModalContentConnector.propTypes = { - ids: PropTypes.arrayOf(PropTypes.number).isRequired, - seriesId: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - updateInteractiveImportItem: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeasonModalContentConnector); diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.js b/frontend/src/InteractiveImport/Season/SelectSeasonRow.js deleted file mode 100644 index 4090ee2d5..000000000 --- a/frontend/src/InteractiveImport/Season/SelectSeasonRow.js +++ /dev/null @@ -1,40 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Link from 'Components/Link/Link'; -import styles from './SelectSeasonRow.css'; - -class SelectSeasonRow extends Component { - - // - // Listeners - - onPress = () => { - this.props.onSeasonSelect(this.props.seasonNumber); - }; - - // - // Render - - render() { - const seasonNumber = this.props.seasonNumber; - - return ( - - { - seasonNumber === 0 ? 'Specials' : `Season ${seasonNumber}` - } - - ); - } -} - -SelectSeasonRow.propTypes = { - seasonNumber: PropTypes.number.isRequired, - onSeasonSelect: PropTypes.func.isRequired -}; - -export default SelectSeasonRow; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.tsx b/frontend/src/InteractiveImport/Season/SelectSeasonRow.tsx new file mode 100644 index 000000000..b8196b06c --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonRow.tsx @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectSeasonRow.css'; + +interface SelectSeasonRowProps { + seasonNumber: number; + onSeasonSelect(season: number): unknown; +} + +function SelectSeasonRow(props: SelectSeasonRowProps) { + const { seasonNumber, onSeasonSelect } = props; + + const onSeasonSelectWrapper = useCallback(() => { + onSeasonSelect(seasonNumber); + }, [seasonNumber, onSeasonSelect]); + + return ( + + {seasonNumber === 0 ? 'Specials' : `Season ${seasonNumber}`} + + ); +} + +export default SelectSeasonRow; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModal.js b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js deleted file mode 100644 index 1a1ceffca..000000000 --- a/frontend/src/InteractiveImport/Series/SelectSeriesModal.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import SelectSeriesModalContentConnector from './SelectSeriesModalContentConnector'; - -class SelectSeriesModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -SelectSeriesModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectSeriesModal; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModal.tsx b/frontend/src/InteractiveImport/Series/SelectSeriesModal.tsx new file mode 100644 index 000000000..af4efa46f --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModal.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import Series from 'Series/Series'; +import SelectSeriesModalContent from './SelectSeriesModalContent'; + +interface SelectSeriesModalProps { + isOpen: boolean; + modalTitle: string; + onSeriesSelect(series: Series): void; + onModalClose(): void; +} + +function SelectSeriesModal(props: SelectSeriesModalProps) { + const { isOpen, modalTitle, onSeriesSelect, onModalClose } = props; + + return ( + + + + ); +} + +export default SelectSeriesModal; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js deleted file mode 100644 index 774ef73c7..000000000 --- a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js +++ /dev/null @@ -1,105 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextInput from 'Components/Form/TextInput'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import Scroller from 'Components/Scroller/Scroller'; -import { scrollDirections } from 'Helpers/Props'; -import SelectSeriesRow from './SelectSeriesRow'; -import styles from './SelectSeriesModalContent.css'; - -class SelectSeriesModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - filter: '' - }; - } - - // - // Listeners - - onFilterChange = ({ value }) => { - this.setState({ filter: value }); - }; - - // - // Render - - render() { - const { - items, - modalTitle, - onSeriesSelect, - onModalClose - } = this.props; - - const filter = this.state.filter; - const filterLower = filter.toLowerCase(); - - return ( - - - {modalTitle} - Select Series - - - - - - - { - items.map((item) => { - return item.title.toLowerCase().includes(filterLower) ? - ( - - ) : - null; - }) - } - - - - - - - - ); - } -} - -SelectSeriesModalContent.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - modalTitle: PropTypes.string.isRequired, - onSeriesSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default SelectSeriesModalContent; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.tsx b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.tsx new file mode 100644 index 000000000..bfa7ce58a --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import TextInput from 'Components/Form/TextInput'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Scroller from 'Components/Scroller/Scroller'; +import { scrollDirections } from 'Helpers/Props'; +import Series from 'Series/Series'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import SelectSeriesRow from './SelectSeriesRow'; +import styles from './SelectSeriesModalContent.css'; + +interface SelectSeriesModalContentProps { + modalTitle: string; + onSeriesSelect(series: Series): void; + onModalClose(): void; +} + +function SelectSeriesModalContent(props: SelectSeriesModalContentProps) { + const { modalTitle, onSeriesSelect, onModalClose } = props; + + const allSeries = useSelector(createAllSeriesSelector()); + const [filter, setFilter] = useState(''); + + const onFilterChange = useCallback( + ({ value }) => { + setFilter(value); + }, + [setFilter] + ); + + const onSeriesSelectWrapper = useCallback( + (seriesId: number) => { + const series = allSeries.find((s) => s.id === seriesId); + + onSeriesSelect(series); + }, + [allSeries, onSeriesSelect] + ); + + const items = useMemo(() => { + const sorted = [...allSeries].sort((a, b) => + a.sortTitle.localeCompare(b.sortTitle) + ); + + return sorted.filter((item) => + item.title.toLowerCase().includes(filter.toLowerCase()) + ); + }, [allSeries, filter]); + + return ( + + {modalTitle} - Select Series + + + + + + {items.map((item) => { + return ( + + ); + })} + + + + + + + + ); +} + +export default SelectSeriesModalContent; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js deleted file mode 100644 index 5fca5762f..000000000 --- a/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js +++ /dev/null @@ -1,86 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { reprocessInteractiveImportItems, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; -import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; -import SelectSeriesModalContent from './SelectSeriesModalContent'; - -function createMapStateToProps() { - return createSelector( - createAllSeriesSelector(), - (items) => { - return { - items: [...items].sort((a, b) => { - if (a.sortTitle < b.sortTitle) { - return -1; - } - - if (a.sortTitle > b.sortTitle) { - return 1; - } - - return 0; - }) - }; - } - ); -} - -const mapDispatchToProps = { - dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems, - dispatchUpdateInteractiveImportItem: updateInteractiveImportItem -}; - -class SelectSeriesModalContentConnector extends Component { - - // - // Listeners - - onSeriesSelect = (seriesId) => { - const { - ids, - items, - dispatchUpdateInteractiveImportItem, - dispatchReprocessInteractiveImportItems, - onModalClose - } = this.props; - - const series = items.find((s) => s.id === seriesId); - - ids.forEach((id) => { - dispatchUpdateInteractiveImportItem({ - id, - series, - seasonNumber: undefined, - episodes: [] - }); - }); - - dispatchReprocessInteractiveImportItems({ ids }); - - onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -SelectSeriesModalContentConnector.propTypes = { - ids: PropTypes.arrayOf(PropTypes.number).isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired, - dispatchUpdateInteractiveImportItem: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeriesModalContentConnector); diff --git a/frontend/src/Language/Language.ts b/frontend/src/Language/Language.ts new file mode 100644 index 000000000..e2b131629 --- /dev/null +++ b/frontend/src/Language/Language.ts @@ -0,0 +1,6 @@ +interface Language { + id: number; + name: string; +} + +export default Language; diff --git a/frontend/src/Quality/Quality.ts b/frontend/src/Quality/Quality.ts new file mode 100644 index 000000000..5aa785a7c --- /dev/null +++ b/frontend/src/Quality/Quality.ts @@ -0,0 +1,30 @@ +export enum QualitySource { + Unknown = 'unkonwn', + Television = 'television', + TelevisionRaw = 'televisionRaw', + Web = 'web', + WebRip = 'webRip', + DVD = 'dvd', + Bluray = 'bluray', + BlurayRaw = 'blurayRaw', +} + +export interface Revision { + version: number; + real: number; + isRepack: boolean; +} + +interface Quality { + id: number; + name: string; + resolution: number; + source: QualitySource; +} + +export interface QualityModel { + quality: Quality; + revision: Revision; +} + +export default Quality; diff --git a/frontend/src/Store/Actions/episodeSelectionActions.js b/frontend/src/Store/Actions/episodeSelectionActions.js new file mode 100644 index 000000000..14c39aa07 --- /dev/null +++ b/frontend/src/Store/Actions/episodeSelectionActions.js @@ -0,0 +1,61 @@ +import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; + +// +// Variables + +export const section = 'episodeSelection'; + +// +// State + +export const defaultState = { + isFetching: false, + isReprocessing: false, + isPopulated: false, + error: null, + sortKey: 'episodeNumber', + sortDirection: sortDirections.ASCENDING, + items: [] +}; + +// +// Actions Types + +export const FETCH_EPISODES = 'episodeSelection/fetchEpisodes'; +export const SET_EPISODES_SORT = 'episodeSelection/setEpisodesSort'; +export const CLEAR_EPISODES = 'episodeSelection/clearEpisodes'; + +// +// Action Creators + +export const fetchEpisodes = createThunk(FETCH_EPISODES); +export const setEpisodesSort = createAction(SET_EPISODES_SORT); +export const clearEpisodes = createAction(CLEAR_EPISODES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EPISODES]: createFetchHandler(section, '/episode') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(section), + + [CLEAR_EPISODES]: (state) => { + return updateSectionState(state, section, { + ...defaultState + }); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index d69f19b11..c92a42d88 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -8,6 +8,7 @@ import * as customFilters from './customFilterActions'; import * as episodes from './episodeActions'; import * as episodeFiles from './episodeFileActions'; import * as episodeHistory from './episodeHistoryActions'; +import * as episodeSelection from './episodeSelectionActions'; import * as history from './historyActions'; import * as importSeries from './importSeriesActions'; import * as interactiveImportActions from './interactiveImportActions'; @@ -37,6 +38,7 @@ export default [ episodes, episodeFiles, episodeHistory, + episodeSelection, history, importSeries, interactiveImportActions, diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index a1eccef7d..aae5e0e7b 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -4,10 +4,8 @@ import { batchActions } from 'redux-batched-actions'; import { sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; -import updateSectionState from 'Utilities/State/updateSectionState'; import naturalExpansion from 'Utilities/String/naturalExpansion'; import { set, update, updateItem } from './baseActions'; -import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; @@ -16,7 +14,6 @@ import createSetClientSideCollectionSortReducer from './Creators/Reducers/create export const section = 'interactiveImport'; -const episodesSection = `${section}.episodes`; let abortCurrentRequest = null; let currentIds = []; @@ -51,16 +48,6 @@ export const defaultState = { quality: function(item, direction) { return item.qualityWeight || 0; } - }, - - episodes: { - isFetching: false, - isReprocessing: false, - isPopulated: false, - error: null, - sortKey: 'episodeNumber', - sortDirection: sortDirections.ASCENDING, - items: [] } }; @@ -84,10 +71,6 @@ export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder'; export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder'; export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode'; -export const FETCH_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/fetchInteractiveImportEpisodes'; -export const SET_INTERACTIVE_IMPORT_EPISODES_SORT = 'interactiveImport/setInteractiveImportEpisodesSort'; -export const CLEAR_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/clearInteractiveImportEpisodes'; - // // Action Creators @@ -101,10 +84,6 @@ export const addRecentFolder = createAction(ADD_RECENT_FOLDER); export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER); export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE); -export const fetchInteractiveImportEpisodes = createThunk(FETCH_INTERACTIVE_IMPORT_EPISODES); -export const setInteractiveImportEpisodesSort = createAction(SET_INTERACTIVE_IMPORT_EPISODES_SORT); -export const clearInteractiveImportEpisodes = createAction(CLEAR_INTERACTIVE_IMPORT_EPISODES); - // // Action Handlers export const actionHandlers = handleThunks({ @@ -218,9 +197,7 @@ export const actionHandlers = handleThunks({ })) )); }); - }, - - [FETCH_INTERACTIVE_IMPORT_EPISODES]: createFetchHandler('interactiveImport.episodes', '/episode') + } }); // @@ -242,13 +219,13 @@ export const reducers = createHandleActions({ }, [UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => { - const ids = payload.ids; + const { ids, ...otherPayload } = payload; const newState = Object.assign({}, state); const items = [...newState.items]; ids.forEach((id) => { const index = items.findIndex((item) => item.id === id); - const item = Object.assign({}, items[index], payload); + const item = Object.assign({}, items[index], otherPayload); items.splice(index, 1, item); }); @@ -299,14 +276,6 @@ export const reducers = createHandleActions({ [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) { return Object.assign({}, state, { importMode: payload.importMode }); - }, - - [SET_INTERACTIVE_IMPORT_EPISODES_SORT]: createSetClientSideCollectionSortReducer(episodesSection), - - [CLEAR_INTERACTIVE_IMPORT_EPISODES]: (state) => { - return updateSectionState(state, episodesSection, { - ...defaultState.episodes - }); } }, defaultState, section); diff --git a/frontend/src/typings/Rejection.ts b/frontend/src/typings/Rejection.ts new file mode 100644 index 000000000..51bed65fc --- /dev/null +++ b/frontend/src/typings/Rejection.ts @@ -0,0 +1,11 @@ +export enum RejectionType { + Permanent = 'permanent', + Temporary = 'temporary', +} + +interface Rejection { + reason: string; + type: RejectionType; +} + +export default Rejection;