diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css index 110c7e01c..c94e383b1 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -11,3 +11,7 @@ border-color: var(--usenetColor); background-color: var(--usenetColor); } + +.unknown { + composes: label from '~Components/Label.css'; +} diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts index f3b389e3d..ba0cb260d 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts +++ b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'torrent': string; + 'unknown': string; 'usenet': string; } export const cssExports: CssExports; diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js deleted file mode 100644 index e8a08943c..000000000 --- a/frontend/src/Activity/Queue/ProtocolLabel.js +++ /dev/null @@ -1,20 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import styles from './ProtocolLabel.css'; - -function ProtocolLabel({ protocol }) { - const protocolName = protocol === 'usenet' ? 'nzb' : protocol; - - return ( - - ); -} - -ProtocolLabel.propTypes = { - protocol: PropTypes.string.isRequired -}; - -export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/ProtocolLabel.tsx b/frontend/src/Activity/Queue/ProtocolLabel.tsx new file mode 100644 index 000000000..c1824452a --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Label from 'Components/Label'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import styles from './ProtocolLabel.css'; + +interface ProtocolLabelProps { + protocol: DownloadProtocol; +} + +function ProtocolLabel({ protocol }: ProtocolLabelProps) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ; +} + +export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js deleted file mode 100644 index 55c1e283d..000000000 --- a/frontend/src/Activity/Queue/Queue.js +++ /dev/null @@ -1,374 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons, kinds } from 'Helpers/Props'; -import getRemovedItems from 'Utilities/Object/getRemovedItems'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import translate from 'Utilities/String/translate'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import QueueFilterModal from './QueueFilterModal'; -import QueueOptionsConnector from './QueueOptionsConnector'; -import QueueRowConnector from './QueueRowConnector'; -import RemoveQueueItemModal from './RemoveQueueItemModal'; - -class Queue extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._shouldBlockRefresh = false; - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - isPendingSelected: false, - isConfirmRemoveModalOpen: false, - items: props.items - }; - } - - shouldComponentUpdate() { - if (this._shouldBlockRefresh) { - return false; - } - - return true; - } - - componentDidUpdate(prevProps) { - const { - items, - isFetching, - isMoviesFetching - } = this.props; - - if ( - (!isMoviesFetching && prevProps.isMoviesFetching) || - (!isFetching && prevProps.isFetching) || - (hasDifferentItems(prevProps.items, items) && !items.some((e) => e.movieId)) - ) { - this.setState((state) => { - return { - ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), - items - }; - }); - - return; - } - - const nextState = {}; - - if (prevProps.items !== items) { - nextState.items = items; - } - - const selectedIds = this.getSelectedIds(); - const isPendingSelected = _.some(this.props.items, (item) => { - return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; - }); - - if (isPendingSelected !== this.state.isPendingSelected) { - nextState.isPendingSelected = isPendingSelected; - } - - if (!_.isEmpty(nextState)) { - this.setState(nextState); - } - } - - // - // Control - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState); - }; - - // - // Listeners - - onQueueRowModalOpenOrClose = (isOpen) => { - this._shouldBlockRefresh = isOpen; - }; - - 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); - }); - }; - - onGrabSelectedPress = () => { - this.props.onGrabSelectedPress(this.getSelectedIds()); - }; - - onRemoveSelectedPress = () => { - this.setState({ isConfirmRemoveModalOpen: true }, () => { - this._shouldBlockRefresh = true; - }); - }; - - onRemoveSelectedConfirmed = (payload) => { - this._shouldBlockRefresh = false; - this.props.onRemoveSelectedPress({ ids: this.getSelectedIds(), ...payload }); - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - onConfirmRemoveModalClose = () => { - this._shouldBlockRefresh = false; - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - isMoviesFetching, - isMoviesPopulated, - moviesError, - columns, - selectedFilterKey, - filters, - customFilters, - count, - totalRecords, - isGrabbing, - isRemoving, - isRefreshMonitoredDownloadsExecuting, - onRefreshPress, - onFilterSelect, - ...otherProps - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - isConfirmRemoveModalOpen, - isPendingSelected, - items - } = this.state; - - const isRefreshing = isFetching || isMoviesFetching || isRefreshMonitoredDownloadsExecuting; - const isAllPopulated = isPopulated && (isMoviesPopulated || !items.length || items.every((e) => !e.movieId)); - const hasError = error || moviesError; - const selectedIds = this.getSelectedIds(); - const selectedCount = selectedIds.length; - const disableSelectedActions = selectedCount === 0; - - return ( - - - - - - - - - - - - - - - - - - - - - - - { - isRefreshing && !isAllPopulated ? - : - null - } - - { - !isRefreshing && hasError ? - - {translate('QueueLoadError')} - : - null - } - - { - isAllPopulated && !hasError && !items.length ? - - { - selectedFilterKey !== 'all' && count > 0 ? - translate('QueueFilterHasNoItems') : - translate('QueueIsEmpty') - } - : - null - } - - { - isAllPopulated && !hasError && !!items.length ? -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
: - null - } -
- - { - const item = items.find((i) => i.id === id); - - return !!(item && item.downloadClientHasPostImportCategory); - }) - )} - canIgnore={isConfirmRemoveModalOpen && ( - selectedIds.every((id) => { - const item = items.find((i) => i.id === id); - - return !!(item && item.movieId); - }) - )} - pending={isConfirmRemoveModalOpen && ( - selectedIds.every((id) => { - const item = items.find((i) => i.id === id); - - if (!item) { - return false; - } - - return item.status === 'delay' || item.status === 'downloadClientUnavailable'; - }) - )} - onRemovePress={this.onRemoveSelectedConfirmed} - onModalClose={this.onConfirmRemoveModalClose} - /> -
- ); - } -} - -Queue.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - isMoviesFetching: PropTypes.bool.isRequired, - isMoviesPopulated: PropTypes.bool.isRequired, - moviesError: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - count: PropTypes.number.isRequired, - totalRecords: PropTypes.number, - isGrabbing: PropTypes.bool.isRequired, - isRemoving: PropTypes.bool.isRequired, - isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onGrabSelectedPress: PropTypes.func.isRequired, - onRemoveSelectedPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -Queue.defaultProps = { - count: 0 -}; - -export default Queue; diff --git a/frontend/src/Activity/Queue/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx new file mode 100644 index 000000000..536d07c09 --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -0,0 +1,400 @@ +import React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { align, icons, kinds } from 'Helpers/Props'; +import createMoviesFetchingSelector from 'Movie/createMoviesFetchingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { + clearQueue, + fetchQueue, + gotoQueuePage, + grabQueueItems, + removeQueueItems, + setQueueFilter, + setQueueSort, + setQueueTableOption, +} from 'Store/Actions/queueActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { CheckInputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import { TableOptionsChangePayload } from 'typings/Table'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import QueueFilterModal from './QueueFilterModal'; +import QueueOptions from './QueueOptions'; +import QueueRow from './QueueRow'; +import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; +import createQueueStatusSelector from './Status/createQueueStatusSelector'; + +function Queue() { + const requestCurrentPage = useCurrentPage(); + const dispatch = useDispatch(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + pageSize, + totalPages, + totalRecords, + isGrabbing, + isRemoving, + } = useSelector((state: AppState) => state.queue.paged); + + const { count } = useSelector(createQueueStatusSelector()); + const { isMoviesFetching, isMoviesPopulated, moviesError } = useSelector( + createMoviesFetchingSelector() + ); + const customFilters = useSelector(createCustomFiltersSelector('queue')); + + const isRefreshMonitoredDownloadsExecuting = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS) + ); + + const shouldBlockRefresh = useRef(false); + const currentQueue = useRef(null); + + const [selectState, setSelectState] = useSelectState(); + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const isPendingSelected = useMemo(() => { + return items.some((item) => { + return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; + }); + }, [items, selectedIds]); + + const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = + useState(false); + + const isRefreshing = + isFetching || isMoviesFetching || isRefreshMonitoredDownloadsExecuting; + const isAllPopulated = + isPopulated && + (isMoviesPopulated || !items.length || items.every((m) => !m.movieId)); + const hasError = error || moviesError; + const selectedCount = selectedIds.length; + const disableSelectedActions = selectedCount === 0; + + const handleSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey = false }: SelectStateInputProps) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const handleRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.REFRESH_MONITORED_DOWNLOADS, + }) + ); + }, [dispatch]); + + const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => { + shouldBlockRefresh.current = isOpen; + }, []); + + const handleGrabSelectedPress = useCallback(() => { + dispatch(grabQueueItems({ ids: selectedIds })); + }, [selectedIds, dispatch]); + + const handleRemoveSelectedPress = useCallback(() => { + shouldBlockRefresh.current = true; + setIsConfirmRemoveModalOpen(true); + }, [setIsConfirmRemoveModalOpen]); + + const handleRemoveSelectedConfirmed = useCallback( + (payload: RemovePressProps) => { + shouldBlockRefresh.current = false; + dispatch(removeQueueItems({ ids: selectedIds, ...payload })); + setIsConfirmRemoveModalOpen(false); + }, + [selectedIds, setIsConfirmRemoveModalOpen, dispatch] + ); + + const handleConfirmRemoveModalClose = useCallback(() => { + shouldBlockRefresh.current = false; + setIsConfirmRemoveModalOpen(false); + }, [setIsConfirmRemoveModalOpen]); + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoQueuePage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setQueueFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setQueueSort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setQueueTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoQueuePage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchQueue()); + } else { + dispatch(gotoQueuePage({ page: 1 })); + } + + return () => { + dispatch(clearQueue()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchQueue()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + if (!shouldBlockRefresh.current) { + currentQueue.current = ( + + {isRefreshing && !isAllPopulated ? : null} + + {!isRefreshing && hasError ? ( + {translate('QueueLoadError')} + ) : null} + + {isAllPopulated && !hasError && !items.length ? ( + + {selectedFilterKey !== 'all' && count > 0 + ? translate('QueueFilterHasNoItems') + : translate('QueueIsEmpty')} + + ) : null} + + {isAllPopulated && !hasError && !!items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ + +
+ ) : null} +
+ ); + } + + return ( + + + + + + + + + + + + + + + + + + + + + + {currentQueue.current} + + { + const item = items.find((i) => i.id === id); + + return !!(item && item.downloadClientHasPostImportCategory); + }) + } + canIgnore={ + isConfirmRemoveModalOpen && + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + return !!(item && item.movieId); + }) + } + isPending={ + isConfirmRemoveModalOpen && + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + if (!item) { + return false; + } + + return ( + item.status === 'delay' || + item.status === 'downloadClientUnavailable' + ); + }) + } + onRemovePress={handleRemoveSelectedConfirmed} + onModalClose={handleConfirmRemoveModalClose} + /> + + ); +} + +export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js deleted file mode 100644 index 573785f71..000000000 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ /dev/null @@ -1,185 +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 withCurrentPage from 'Components/withCurrentPage'; -import { executeCommand } from 'Store/Actions/commandActions'; -import * as queueActions from 'Store/Actions/queueActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Queue from './Queue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movies, - (state) => state.queue.options, - (state) => state.queue.paged, - (state) => state.queue.status.item, - createCustomFiltersSelector('queue'), - createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), - (movies, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => { - return { - count: options.includeUnknownMovieItems ? status.totalCount : status.count, - isMoviesFetching: movies.isFetching, - isMoviesPopulated: movies.isPopulated, - moviesError: movies.error, - customFilters, - isRefreshMonitoredDownloadsExecuting, - ...options, - ...queue - }; - } - ); -} - -const mapDispatchToProps = { - ...queueActions, - executeCommand -}; - -class QueueConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchQueue, - fetchQueueStatus, - gotoQueueFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchQueue(); - } else { - gotoQueueFirstPage(); - } - - fetchQueueStatus(); - } - - componentDidUpdate(prevProps) { - if ( - this.props.includeUnknownMovieItems !== - prevProps.includeUnknownMovieItems - ) { - this.repopulate(); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearQueue(); - } - - // - // Control - - repopulate = () => { - this.props.fetchQueue(); - }; - - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoQueueFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoQueuePreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoQueueNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoQueueLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoQueuePage({ page }); - }; - - onSortPress = (sortKey) => { - this.props.setQueueSort({ sortKey }); - }; - - onFilterSelect = (selectedFilterKey) => { - this.props.setQueueFilter({ selectedFilterKey }); - }; - - onTableOptionChange = (payload) => { - this.props.setQueueTableOption(payload); - - if (payload.pageSize) { - this.props.gotoQueueFirstPage(); - } - }; - - onRefreshPress = () => { - this.props.executeCommand({ - name: commandNames.REFRESH_MONITORED_DOWNLOADS - }); - }; - - onGrabSelectedPress = (ids) => { - this.props.grabQueueItems({ ids }); - }; - - onRemoveSelectedPress = (payload) => { - this.props.removeQueueItems(payload); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -QueueConnector.propTypes = { - includeUnknownMovieItems: PropTypes.bool.isRequired, - useCurrentPage: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchQueue: PropTypes.func.isRequired, - fetchQueueStatus: PropTypes.func.isRequired, - gotoQueueFirstPage: PropTypes.func.isRequired, - gotoQueuePreviousPage: PropTypes.func.isRequired, - gotoQueueNextPage: PropTypes.func.isRequired, - gotoQueueLastPage: PropTypes.func.isRequired, - gotoQueuePage: PropTypes.func.isRequired, - setQueueSort: PropTypes.func.isRequired, - setQueueFilter: PropTypes.func.isRequired, - setQueueTableOption: PropTypes.func.isRequired, - clearQueue: PropTypes.func.isRequired, - grabQueueItems: PropTypes.func.isRequired, - removeQueueItems: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) -); diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.tsx similarity index 60% rename from frontend/src/Activity/Queue/QueueDetails.js rename to frontend/src/Activity/Queue/QueueDetails.tsx index 7014bf5da..db62de3e1 100644 --- a/frontend/src/Activity/Queue/QueueDetails.js +++ b/frontend/src/Activity/Queue/QueueDetails.tsx @@ -1,36 +1,49 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import Popover from 'Components/Tooltip/Popover'; import { icons, tooltipPositions } from 'Helpers/Props'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; import translate from 'Utilities/String/translate'; import QueueStatus from './QueueStatus'; import styles from './QueueDetails.css'; -function QueueDetails(props) { +interface QueueDetailsProps { + title: string; + size: number; + sizeleft: number; + estimatedCompletionTime?: string; + status: string; + trackedDownloadState?: QueueTrackedDownloadState; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + statusMessages?: StatusMessage[]; + errorMessage?: string; + progressBar: React.ReactNode; +} + +function QueueDetails(props: QueueDetailsProps) { const { title, size, sizeleft, status, - trackedDownloadState, - trackedDownloadStatus, + trackedDownloadState = 'downloading', + trackedDownloadStatus = 'ok', statusMessages, errorMessage, - progressBar + progressBar, } = props; - const progress = size ? (100 - sizeleft / size * 100) : 0; + const progress = size ? 100 - (sizeleft / size) * 100 : 0; const isDownloading = status === 'downloading'; const isPaused = status === 'paused'; const hasWarning = trackedDownloadStatus === 'warning'; const hasError = trackedDownloadStatus === 'error'; - if ( - (isDownloading || isPaused) && - !hasWarning && - !hasError - ) { + if ((isDownloading || isPaused) && !hasWarning && !hasError) { const state = isPaused ? translate('Paused') : translate('Downloading'); if (progress < 5) { @@ -45,11 +58,9 @@ function QueueDetails(props) { return ( {title} - } + body={
{title}
} position={tooltipPositions.LEFT} /> ); @@ -68,22 +79,4 @@ function QueueDetails(props) { ); } -QueueDetails.propTypes = { - title: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - estimatedCompletionTime: PropTypes.string, - status: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - progressBar: PropTypes.node.isRequired -}; - -QueueDetails.defaultProps = { - trackedDownloadStatus: 'ok', - trackedDownloadState: 'downloading' -}; - export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js deleted file mode 100644 index adf0c25b1..000000000 --- a/frontend/src/Activity/Queue/QueueOptions.js +++ /dev/null @@ -1,78 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class QueueOptions extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - includeUnknownMovieItems: props.includeUnknownMovieItems - }; - } - - componentDidUpdate(prevProps) { - const { - includeUnknownMovieItems - } = this.props; - - if (includeUnknownMovieItems !== prevProps.includeUnknownMovieItems) { - this.setState({ - includeUnknownMovieItems - }); - } - } - - // - // Listeners - - onOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onOptionChange({ - [name]: value - }); - }); - }; - - // - // Render - - render() { - const { - includeUnknownMovieItems - } = this.state; - - return ( - - - {translate('ShowUnknownMovieItems')} - - - - - ); - } -} - -QueueOptions.propTypes = { - includeUnknownMovieItems: PropTypes.bool.isRequired, - onOptionChange: PropTypes.func.isRequired -}; - -export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptions.tsx b/frontend/src/Activity/Queue/QueueOptions.tsx new file mode 100644 index 000000000..43b2abaa8 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import { inputTypes } from 'Helpers/Props'; +import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions'; +import { CheckInputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; + +function QueueOptions() { + const dispatch = useDispatch(); + const { includeUnknownMovieItems } = useSelector( + (state: AppState) => state.queue.options + ); + + const handleOptionChange = useCallback( + ({ name, value }: CheckInputChanged) => { + dispatch( + setQueueOption({ + [name]: value, + }) + ); + + if (name === 'includeUnknownMovieItems') { + dispatch(gotoQueuePage({ page: 1 })); + } + }, + [dispatch] + ); + + return ( + + {translate('ShowUnknownMovieItems')} + + + + ); +} + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js deleted file mode 100644 index b2c99511c..000000000 --- a/frontend/src/Activity/Queue/QueueOptionsConnector.js +++ /dev/null @@ -1,19 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setQueueOption } from 'Store/Actions/queueActions'; -import QueueOptions from './QueueOptions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.queue.options, - (options) => { - return options; - } - ); -} - -const mapDispatchToProps = { - onOptionChange: setQueueOption -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js deleted file mode 100644 index e1b1551a4..000000000 --- a/frontend/src/Activity/Queue/QueueRow.js +++ /dev/null @@ -1,434 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ProgressBar from 'Components/ProgressBar'; -import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import TableRow from 'Components/Table/TableRow'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import MovieFormats from 'Movie/MovieFormats'; -import MovieLanguages from 'Movie/MovieLanguages'; -import MovieQuality from 'Movie/MovieQuality'; -import MovieTitleLink from 'Movie/MovieTitleLink'; -import formatBytes from 'Utilities/Number/formatBytes'; -import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; -import translate from 'Utilities/String/translate'; -import QueueStatusCell from './QueueStatusCell'; -import RemoveQueueItemModal from './RemoveQueueItemModal'; -import TimeleftCell from './TimeleftCell'; -import styles from './QueueRow.css'; - -class QueueRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isRemoveQueueItemModalOpen: false, - isInteractiveImportModalOpen: false - }; - } - - // - // Listeners - - onRemoveQueueItemPress = () => { - this.setState({ isRemoveQueueItemModalOpen: true }); - }; - - onRemoveQueueItemModalConfirmed = (blocklist, skipRedownload) => { - const { - onRemoveQueueItemPress, - onQueueRowModalOpenOrClose - } = this.props; - - onQueueRowModalOpenOrClose(false); - onRemoveQueueItemPress(blocklist, skipRedownload); - - this.setState({ isRemoveQueueItemModalOpen: false }); - }; - - onRemoveQueueItemModalClose = () => { - this.props.onQueueRowModalOpenOrClose(false); - - this.setState({ isRemoveQueueItemModalOpen: false }); - }; - - onInteractiveImportPress = () => { - this.props.onQueueRowModalOpenOrClose(true); - - this.setState({ isInteractiveImportModalOpen: true }); - }; - - onInteractiveImportModalClose = () => { - this.props.onQueueRowModalOpenOrClose(false); - - this.setState({ isInteractiveImportModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - downloadId, - title, - status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, - errorMessage, - movie, - quality, - customFormats, - customFormatScore, - languages, - protocol, - indexer, - outputPath, - downloadClient, - downloadClientHasPostImportCategory, - estimatedCompletionTime, - added, - timeleft, - size, - sizeleft, - showRelativeDates, - shortDateFormat, - timeFormat, - isGrabbing, - grabError, - isRemoving, - isSelected, - columns, - onSelectedChange, - onGrabPress - } = this.props; - - const { - isRemoveQueueItemModalOpen, - isInteractiveImportModalOpen - } = this.state; - - const progress = 100 - (sizeleft / size * 100); - const showInteractiveImport = status === 'completed' && trackedDownloadStatus === 'warning'; - const isPending = status === 'delay' || status === 'downloadClientUnavailable'; - - return ( - - - - { - columns.map((column) => { - - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'status') { - return ( - - ); - } - - if (name === 'movies.sortTitle') { - return ( - - { - movie ? - : - title - } - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - { - quality ? - : - null - } - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'customFormatScore') { - return ( - - } - position={tooltipPositions.BOTTOM} - /> - - ); - } - - if (name === 'protocol') { - return ( - - - - ); - } - - if (name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (name === 'downloadClient') { - return ( - - {downloadClient} - - ); - } - - if (name === 'size') { - return ( - - {formatBytes(size)} - - ); - } - - if (name === 'year') { - return ( - - { - movie ? movie.year : '' - } - - ); - } - - if (name === 'title') { - return ( - - {title} - - ); - } - - if (name === 'outputPath') { - return ( - - {outputPath} - - ); - } - - if (name === 'estimatedCompletionTime') { - return ( - - ); - } - - if (name === 'progress') { - return ( - - { - !!progress && - - } - - ); - } - - if (name === 'added') { - return ( - - ); - } - - if (name === 'actions') { - return ( - - { - showInteractiveImport && - - } - - { - isPending && - - } - - - - ); - } - - return null; - }) - } - - - - - - ); - } - -} - -QueueRow.propTypes = { - id: PropTypes.number.isRequired, - downloadId: PropTypes.string, - title: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string, - trackedDownloadState: PropTypes.string, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - movie: PropTypes.object, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.isRequired, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - outputPath: PropTypes.string, - downloadClient: PropTypes.string, - downloadClientHasPostImportCategory: PropTypes.bool, - estimatedCompletionTime: PropTypes.string, - added: PropTypes.string, - timeleft: PropTypes.string, - size: PropTypes.number, - year: PropTypes.number, - sizeleft: PropTypes.number, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isGrabbing: PropTypes.bool.isRequired, - grabError: PropTypes.object, - isRemoving: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onSelectedChange: PropTypes.func.isRequired, - onGrabPress: PropTypes.func.isRequired, - onRemoveQueueItemPress: PropTypes.func.isRequired, - onQueueRowModalOpenOrClose: PropTypes.func.isRequired -}; - -QueueRow.defaultProps = { - customFormats: [], - isGrabbing: false, - isRemoving: false -}; - -export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRow.tsx b/frontend/src/Activity/Queue/QueueRow.tsx new file mode 100644 index 000000000..e61d53eee --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -0,0 +1,361 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import { Error } from 'App/State/AppSectionState'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import Language from 'Language/Language'; +import MovieFormats from 'Movie/MovieFormats'; +import MovieLanguages from 'Movie/MovieLanguages'; +import MovieQuality from 'Movie/MovieQuality'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import useMovie from 'Movie/useMovie'; +import { QualityModel } from 'Quality/Quality'; +import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CustomFormat from 'typings/CustomFormat'; +import { SelectStateInputProps } from 'typings/props'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import formatBytes from 'Utilities/Number/formatBytes'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import QueueStatusCell from './QueueStatusCell'; +import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; +import TimeleftCell from './TimeleftCell'; +import styles from './QueueRow.css'; + +interface QueueRowProps { + id: number; + movieId?: number; + downloadId?: string; + title: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; + languages: Language[]; + quality: QualityModel; + customFormats?: CustomFormat[]; + customFormatScore: number; + protocol: DownloadProtocol; + indexer?: string; + outputPath?: string; + downloadClient?: string; + downloadClientHasPostImportCategory?: boolean; + estimatedCompletionTime?: string; + added?: string; + timeleft?: string; + size: number; + sizeleft: number; + isGrabbing?: boolean; + grabError?: Error; + isRemoving?: boolean; + isSelected?: boolean; + columns: Column[]; + onSelectedChange: (options: SelectStateInputProps) => void; + onQueueRowModalOpenOrClose: (isOpen: boolean) => void; +} + +function QueueRow(props: QueueRowProps) { + const { + id, + movieId, + downloadId, + title, + status, + trackedDownloadStatus, + trackedDownloadState, + statusMessages, + errorMessage, + languages, + quality, + customFormats = [], + customFormatScore, + protocol, + indexer, + outputPath, + downloadClient, + downloadClientHasPostImportCategory, + estimatedCompletionTime, + added, + timeleft, + size, + sizeleft, + isGrabbing = false, + grabError, + isRemoving = false, + isSelected, + columns, + onSelectedChange, + onQueueRowModalOpenOrClose, + } = props; + + const dispatch = useDispatch(); + const movie = useMovie(movieId); + const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] = + useState(false); + + const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] = + useState(false); + + const handleGrabPress = useCallback(() => { + dispatch(grabQueueItem({ id })); + }, [id, dispatch]); + + const handleInteractiveImportPress = useCallback(() => { + onQueueRowModalOpenOrClose(true); + setIsInteractiveImportModalOpen(true); + }, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]); + + const handleInteractiveImportModalClose = useCallback(() => { + onQueueRowModalOpenOrClose(false); + setIsInteractiveImportModalOpen(false); + }, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]); + + const handleRemoveQueueItemPress = useCallback(() => { + onQueueRowModalOpenOrClose(true); + setIsRemoveQueueItemModalOpen(true); + }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); + + const handleRemoveQueueItemModalConfirmed = useCallback( + (payload: RemovePressProps) => { + onQueueRowModalOpenOrClose(false); + dispatch(removeQueueItem({ id, ...payload })); + setIsRemoveQueueItemModalOpen(false); + }, + [id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch] + ); + + const handleRemoveQueueItemModalClose = useCallback(() => { + onQueueRowModalOpenOrClose(false); + setIsRemoveQueueItemModalOpen(false); + }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); + + const progress = size ? 100 - (sizeleft / size) * 100 : 0; + const showInteractiveImport = + status === 'completed' && trackedDownloadStatus === 'warning'; + const isPending = + status === 'delay' || status === 'downloadClientUnavailable'; + + return ( + + + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'movies.sortTitle') { + return ( + + {movie ? ( + + ) : ( + title + )} + + ); + } + + if (name === 'year') { + return ( + {movie ? movie.year : ''} + ); + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + {quality ? : null} + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'indexer') { + return {indexer}; + } + + if (name === 'downloadClient') { + return {downloadClient}; + } + + if (name === 'title') { + return {title}; + } + + if (name === 'size') { + return {formatBytes(size)}; + } + + if (name === 'outputPath') { + return {outputPath}; + } + + if (name === 'estimatedCompletionTime') { + return ( + + ); + } + + if (name === 'progress') { + return ( + + {!!progress && ( + + )} + + ); + } + + if (name === 'added') { + return ; + } + + if (name === 'actions') { + return ( + + {showInteractiveImport ? ( + + ) : null} + + {isPending ? ( + + ) : null} + + + + ); + } + + return null; + })} + + + + + + ); +} + +export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js deleted file mode 100644 index d6e5a1173..000000000 --- a/frontend/src/Activity/Queue/QueueRowConnector.js +++ /dev/null @@ -1,67 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import QueueRow from './QueueRow'; - -function createMapStateToProps() { - return createSelector( - createMovieSelector(), - createUISettingsSelector(), - (movie, uiSettings) => { - const result = { - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - - result.movie = movie; - - return result; - } - ); -} - -const mapDispatchToProps = { - grabQueueItem, - removeQueueItem -}; - -class QueueRowConnector extends Component { - - // - // Listeners - - onGrabPress = () => { - this.props.grabQueueItem({ id: this.props.id }); - }; - - onRemoveQueueItemPress = (payload) => { - this.props.removeQueueItem({ id: this.props.id, ...payload }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -QueueRowConnector.propTypes = { - id: PropTypes.number.isRequired, - movie: PropTypes.object, - grabQueueItem: PropTypes.func.isRequired, - removeQueueItem: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector); diff --git a/frontend/src/Activity/Queue/QueueStatus.js b/frontend/src/Activity/Queue/QueueStatus.tsx similarity index 60% rename from frontend/src/Activity/Queue/QueueStatus.js rename to frontend/src/Activity/Queue/QueueStatus.tsx index f7cab31ca..64d802df8 100644 --- a/frontend/src/Activity/Queue/QueueStatus.js +++ b/frontend/src/Activity/Queue/QueueStatus.tsx @@ -1,51 +1,59 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; +import TooltipPosition from 'Helpers/Props/TooltipPosition'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; import translate from 'Utilities/String/translate'; import styles from './QueueStatus.css'; -function getDetailedPopoverBody(statusMessages) { +function getDetailedPopoverBody(statusMessages: StatusMessage[]) { return (
- { - statusMessages.map(({ title, messages }) => { - return ( -
- {title} -
    - { - messages.map((message) => { - return ( -
  • - {message} -
  • - ); - }) - } -
-
- ); - }) - } + {statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + {messages.map((message) => { + return
  • {message}
  • ; + })} +
+
+ ); + })}
); } -function QueueStatus(props) { +interface QueueStatusProps { + sourceTitle: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; + position: TooltipPosition; + canFlip?: boolean; +} + +function QueueStatus(props: QueueStatusProps) { const { sourceTitle, status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, + trackedDownloadStatus = 'ok', + trackedDownloadState = 'downloading', + statusMessages = [], errorMessage, position, - canFlip + canFlip = false, } = props; const hasWarning = trackedDownloadStatus === 'warning'; @@ -115,7 +123,8 @@ function QueueStatus(props) { if (status === 'warning') { iconName = icons.DOWNLOADING; iconKind = kinds.WARNING; - const warningMessage = errorMessage || translate('CheckDownloadClientForDetails'); + const warningMessage = + errorMessage || translate('CheckDownloadClientForDetails'); title = translate('DownloadWarning', { warningMessage }); } @@ -133,35 +142,17 @@ function QueueStatus(props) { return ( - } + anchor={} title={title} - body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} + body={ + hasWarning || hasError + ? getDetailedPopoverBody(statusMessages) + : sourceTitle + } position={position} canFlip={canFlip} /> ); } -QueueStatus.propTypes = { - sourceTitle: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - position: PropTypes.oneOf(tooltipPositions.all).isRequired, - canFlip: PropTypes.bool.isRequired -}; - -QueueStatus.defaultProps = { - trackedDownloadStatus: 'ok', - trackedDownloadState: 'downloading', - canFlip: false -}; - export default QueueStatus; diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js deleted file mode 100644 index 4e8b9658c..000000000 --- a/frontend/src/Activity/Queue/QueueStatusCell.js +++ /dev/null @@ -1,47 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import { tooltipPositions } from 'Helpers/Props'; -import QueueStatus from './QueueStatus'; -import styles from './QueueStatusCell.css'; - -function QueueStatusCell(props) { - const { - sourceTitle, - status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, - errorMessage - } = props; - - return ( - - - - ); -} - -QueueStatusCell.propTypes = { - sourceTitle: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string -}; - -QueueStatusCell.defaultProps = { - trackedDownloadStatus: 'ok', - trackedDownloadState: 'downloading' -}; - -export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/QueueStatusCell.tsx b/frontend/src/Activity/Queue/QueueStatusCell.tsx new file mode 100644 index 000000000..634e33164 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import QueueStatus from './QueueStatus'; +import styles from './QueueStatusCell.css'; + +interface QueueStatusCellProps { + sourceTitle: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; +} + +function QueueStatusCell(props: QueueStatusCellProps) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'ok', + trackedDownloadState = 'downloading', + statusMessages, + errorMessage, + } = props; + + return ( + + + + ); +} + +export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx index 255c8a562..461fa57ad 100644 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx @@ -12,7 +12,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './RemoveQueueItemModal.css'; -interface RemovePressProps { +export interface RemovePressProps { remove: boolean; changeCategory: boolean; blocklist: boolean; @@ -21,7 +21,7 @@ interface RemovePressProps { interface RemoveQueueItemModalProps { isOpen: boolean; - sourceTitle: string; + sourceTitle?: string; canChangeCategory: boolean; canIgnore: boolean; isPending: boolean; @@ -39,7 +39,7 @@ type BlocklistMethod = function RemoveQueueItemModal(props: RemoveQueueItemModalProps) { const { isOpen, - sourceTitle, + sourceTitle = '', canIgnore, canChangeCategory, isPending, diff --git a/frontend/src/Activity/Queue/Status/QueueStatus.tsx b/frontend/src/Activity/Queue/Status/QueueStatus.tsx new file mode 100644 index 000000000..894434e07 --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatus.tsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; +import createQueueStatusSelector from './createQueueStatusSelector'; + +function QueueStatus() { + const dispatch = useDispatch(); + const { isConnected, isReconnecting } = useSelector( + (state: AppState) => state.app + ); + const { isPopulated, count, errors, warnings } = useSelector( + createQueueStatusSelector() + ); + + const wasReconnecting = usePrevious(isReconnecting); + + useEffect(() => { + if (!isPopulated) { + dispatch(fetchQueueStatus()); + } + }, [isPopulated, dispatch]); + + useEffect(() => { + if (isConnected && wasReconnecting) { + dispatch(fetchQueueStatus()); + } + }, [isConnected, wasReconnecting, dispatch]); + + return ( + + ); +} + +export default QueueStatus; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js deleted file mode 100644 index 9e3662de6..000000000 --- a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; -import { fetchQueueStatus } from 'Store/Actions/queueActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app, - (state) => state.queue.status, - (state) => state.queue.options.includeUnknownMovieItems, - (app, status, includeUnknownMovieItems) => { - const { - errors, - warnings, - unknownErrors, - unknownWarnings, - count, - totalCount - } = status.item; - - return { - isConnected: app.isConnected, - isReconnecting: app.isReconnecting, - isPopulated: status.isPopulated, - ...status.item, - count: includeUnknownMovieItems ? totalCount : count, - errors: includeUnknownMovieItems ? errors || unknownErrors : errors, - warnings: includeUnknownMovieItems ? warnings || unknownWarnings : warnings - }; - } - ); -} - -const mapDispatchToProps = { - fetchQueueStatus -}; - -class QueueStatusConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.fetchQueueStatus(); - } - } - - componentDidUpdate(prevProps) { - if (this.props.isConnected && prevProps.isReconnecting) { - this.props.fetchQueueStatus(); - } - } - - // - // Render - - render() { - return ( - - ); - } -} - -QueueStatusConnector.propTypes = { - isConnected: PropTypes.bool.isRequired, - isReconnecting: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - fetchQueueStatus: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector); diff --git a/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts b/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts new file mode 100644 index 000000000..a8bb3e433 --- /dev/null +++ b/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createQueueStatusSelector() { + return createSelector( + (state: AppState) => state.queue.status.isPopulated, + (state: AppState) => state.queue.status.item, + (state: AppState) => state.queue.options.includeUnknownMovieItems, + (isPopulated, status, includeUnknownMovieItems) => { + const { + errors, + warnings, + unknownErrors, + unknownWarnings, + count, + totalCount, + } = status; + + return { + ...status, + isPopulated, + count: includeUnknownMovieItems ? totalCount : count, + errors: includeUnknownMovieItems ? errors || unknownErrors : errors, + warnings: includeUnknownMovieItems + ? warnings || unknownWarnings + : warnings, + }; + } + ); +} + +export default createQueueStatusSelector; diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.tsx similarity index 76% rename from frontend/src/Activity/Queue/TimeleftCell.js rename to frontend/src/Activity/Queue/TimeleftCell.tsx index 0a39b7edc..917a6ad0d 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.js +++ b/frontend/src/Activity/Queue/TimeleftCell.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; @@ -11,7 +10,18 @@ import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './TimeleftCell.css'; -function TimeleftCell(props) { +interface TimeleftCellProps { + estimatedCompletionTime?: string; + timeleft?: string; + status: string; + size: number; + sizeleft: number; + showRelativeDates: boolean; + shortDateFormat: string; + timeFormat: string; +} + +function TimeleftCell(props: TimeleftCellProps) { const { estimatedCompletionTime, timeleft, @@ -20,16 +30,18 @@ function TimeleftCell(props) { sizeleft, showRelativeDates, shortDateFormat, - timeFormat + timeFormat, } = props; if (status === 'delay') { const date = getRelativeDate({ date: estimatedCompletionTime, shortDateFormat, - showRelativeDates + showRelativeDates, + }); + const time = formatTime(estimatedCompletionTime, timeFormat, { + includeMinuteZero: true, }); - const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( @@ -47,9 +59,11 @@ function TimeleftCell(props) { const date = getRelativeDate({ date: estimatedCompletionTime, shortDateFormat, - showRelativeDates + showRelativeDates, + }); + const time = formatTime(estimatedCompletionTime, timeFormat, { + includeMinuteZero: true, }); - const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( @@ -64,11 +78,7 @@ function TimeleftCell(props) { } if (!timeleft || status === 'completed' || status === 'failed') { - return ( - - - - - ); + return -; } const totalSize = formatBytes(size); @@ -84,15 +94,4 @@ function TimeleftCell(props) { ); } -TimeleftCell.propTypes = { - estimatedCompletionTime: PropTypes.string, - timeleft: PropTypes.string, - status: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - export default TimeleftCell; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index de41ccd4e..c5207e454 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -3,7 +3,7 @@ import React from 'react'; import { Redirect, Route } from 'react-router-dom'; import Blocklist from 'Activity/Blocklist/Blocklist'; import HistoryConnector from 'Activity/History/HistoryConnector'; -import QueueConnector from 'Activity/Queue/QueueConnector'; +import Queue from 'Activity/Queue/Queue'; import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector'; import ImportMovies from 'AddMovie/ImportMovie/ImportMovies'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; @@ -115,7 +115,7 @@ function AppRoutes(props) { { params: unknown; } export interface QueuePagedAppState extends AppSectionState, - AppSectionFilterState { + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState { isGrabbing: boolean; grabError: Error; isRemoving: boolean; @@ -19,9 +33,12 @@ export interface QueuePagedAppState } interface QueueAppState { - status: AppSectionItemState; + status: AppSectionItemState; details: QueueDetailsAppState; paged: QueuePagedAppState; + options: { + includeUnknownMovieItems: boolean; + }; } export default QueueAppState; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 33e68fbc5..48e77b7df 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -3,7 +3,7 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; -import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector'; +import QueueStatus from 'Activity/Queue/Status/QueueStatus'; import OverlayScroller from 'Components/Scroller/OverlayScroller'; import Scroller from 'Components/Scroller/Scroller'; import { icons } from 'Helpers/Props'; @@ -58,7 +58,7 @@ const links = [ { title: () => translate('Queue'), to: '/activity/queue', - statusComponent: QueueStatusConnector + statusComponent: QueueStatus }, { title: () => translate('History'), diff --git a/frontend/src/Helpers/Props/TooltipPosition.ts b/frontend/src/Helpers/Props/TooltipPosition.ts index 7a9351ac6..885c73470 100644 --- a/frontend/src/Helpers/Props/TooltipPosition.ts +++ b/frontend/src/Helpers/Props/TooltipPosition.ts @@ -1,8 +1,3 @@ -enum TooltipPosition { - Top = 'top', - Right = 'right', - Bottom = 'bottom', - Left = 'left', -} +type TooltipPosition = 'top' | 'right' | 'bottom' | 'left'; export default TooltipPosition; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx index dafb80a72..32221a5dc 100644 --- a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx @@ -117,14 +117,13 @@ function getInfoRowProps( return { title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, iconName: icons.ADD, - label: - getRelativeDate({ - date: added, - shortDateFormat, - showRelativeDates, - timeFormat, - timeForToday: true, - }) ?? '', + label: getRelativeDate({ + date: added, + shortDateFormat, + showRelativeDates, + timeFormat, + timeForToday: true, + }), }; } diff --git a/frontend/src/Movie/createMoviesFetchingSelector.ts b/frontend/src/Movie/createMoviesFetchingSelector.ts new file mode 100644 index 000000000..5b69965b8 --- /dev/null +++ b/frontend/src/Movie/createMoviesFetchingSelector.ts @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createMoviesFetchingSelector() { + return createSelector( + (state: AppState) => state.movies, + (movies) => { + return { + isMoviesFetching: movies.isFetching, + isMoviesPopulated: movies.isPopulated, + moviesError: movies.error, + }; + } + ); +} + +export default createMoviesFetchingSelector; diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js index dec7dd79e..214ea92ab 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -72,6 +72,12 @@ export const defaultState = { isSortable: true, isVisible: true }, + { + name: 'year', + label: () => translate('Year'), + isSortable: true, + isVisible: true + }, { name: 'languages', label: () => translate('Languages'), @@ -129,12 +135,6 @@ export const defaultState = { isSortable: true, isVisible: false }, - { - name: 'year', - label: () => translate('Year'), - isSortable: true, - isVisible: true - }, { name: 'outputPath', label: () => translate('OutputPath'), diff --git a/frontend/src/typings/Queue.ts b/frontend/src/typings/Queue.ts index c5f7eb962..861b40293 100644 --- a/frontend/src/typings/Queue.ts +++ b/frontend/src/typings/Queue.ts @@ -1,4 +1,5 @@ import ModelBase from 'App/ModelBase'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; import Language from 'Language/Language'; import { QualityModel } from 'Quality/Quality'; import CustomFormat from 'typings/CustomFormat'; @@ -7,6 +8,7 @@ export type QueueTrackedDownloadStatus = 'ok' | 'warning' | 'error'; export type QueueTrackedDownloadState = | 'downloading' + | 'importBlocked' | 'importPending' | 'importing' | 'imported' @@ -23,6 +25,7 @@ interface Queue extends ModelBase { languages: Language[]; quality: QualityModel; customFormats: CustomFormat[]; + customFormatScore: number; size: number; title: string; sizeleft: number; @@ -35,10 +38,11 @@ interface Queue extends ModelBase { statusMessages: StatusMessage[]; errorMessage: string; downloadId: string; - protocol: string; + protocol: DownloadProtocol; downloadClient: string; outputPath: string; movieHasFile: boolean; movieId?: number; + downloadClientHasPostImportCategory: boolean; } export default Queue; diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 6bd870da3..cfa5d30d6 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -853,6 +853,7 @@ "LogSizeLimit": "Log Size Limit", "LogSizeLimitHelpText": "Maximum log file size in MB before archiving. Default is 1MB.", "Logging": "Logging", + "Logout": "Logout", "Logs": "Logs", "LongDateFormat": "Long Date Format", "LookingForReleaseProfiles1": "Looking for Release Profiles? Try",