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 64bfbc085..000000000 --- a/frontend/src/Activity/Queue/Queue.js +++ /dev/null @@ -1,372 +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, - isEpisodesFetching - } = this.props; - - if ( - (!isEpisodesFetching && prevProps.isEpisodesFetching) || - (hasDifferentItems(prevProps.items, items) && !items.some((e) => e.episodeId)) - ) { - 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, - isEpisodesFetching, - isEpisodesPopulated, - episodesError, - 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 || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; - const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); - const hasError = error || episodesError; - 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.seriesId && item.episodeId); - }) - )} - 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, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isEpisodesFetching: PropTypes.bool.isRequired, - isEpisodesPopulated: PropTypes.bool.isRequired, - episodesError: PropTypes.object, - 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..6c5e0fb1b --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -0,0 +1,411 @@ +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 createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { align, icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; +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 QueueItem from 'typings/Queue'; +import { TableOptionsChangePayload } from 'typings/Table'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +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, + totalPages, + totalRecords, + isGrabbing, + isRemoving, + } = useSelector((state: AppState) => state.queue.paged); + + const { count } = useSelector(createQueueStatusSelector()); + const { isEpisodesFetching, isEpisodesPopulated, episodesError } = + useSelector(createEpisodesFetchingSelector()); + 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 || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; + const isAllPopulated = + isPopulated && + (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); + const hasError = error || episodesError; + 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 episodeIds = selectUniqueIds( + items, + 'episodeId' + ); + + if (episodeIds.length) { + dispatch(fetchEpisodes({ episodeIds })); + } else { + dispatch(clearEpisodes()); + } + }, [items, 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.seriesId && item.episodeId); + }) + } + 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 178cb8e5f..000000000 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ /dev/null @@ -1,203 +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 { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import * as queueActions from 'Store/Actions/queueActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Queue from './Queue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.episodes, - (state) => state.queue.options, - (state) => state.queue.paged, - (state) => state.queue.status.item, - createCustomFiltersSelector('queue'), - createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), - (episodes, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => { - return { - count: options.includeUnknownSeriesItems ? status.totalCount : status.count, - isEpisodesFetching: episodes.isFetching, - isEpisodesPopulated: episodes.isPopulated, - episodesError: episodes.error, - customFilters, - isRefreshMonitoredDownloadsExecuting, - ...options, - ...queue - }; - } - ); -} - -const mapDispatchToProps = { - ...queueActions, - fetchEpisodes, - clearEpisodes, - 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 (hasDifferentItems(prevProps.items, this.props.items)) { - const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); - - if (episodeIds.length) { - this.props.fetchEpisodes({ episodeIds }); - } else { - this.props.clearEpisodes(); - } - } - - if ( - this.props.includeUnknownSeriesItems !== - prevProps.includeUnknownSeriesItems - ) { - this.repopulate(); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearQueue(); - this.props.clearEpisodes(); - } - - // - // 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 = { - includeUnknownSeriesItems: 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, - fetchEpisodes: PropTypes.func.isRequired, - clearEpisodes: 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 abc97b75c..be70ceead 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 = (100 - sizeleft / size * 100); + const progress = 100 - (sizeleft / size) * 100; 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 573b3d9c2..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 = { - includeUnknownSeriesItems: props.includeUnknownSeriesItems - }; - } - - componentDidUpdate(prevProps) { - const { - includeUnknownSeriesItems - } = this.props; - - if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) { - this.setState({ - includeUnknownSeriesItems - }); - } - } - - // - // Listeners - - onOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onOptionChange({ - [name]: value - }); - }); - }; - - // - // Render - - render() { - const { - includeUnknownSeriesItems - } = this.state; - - return ( - - - {translate('ShowUnknownSeriesItems')} - - - - - ); - } -} - -QueueOptions.propTypes = { - includeUnknownSeriesItems: 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..70bb0fdcf --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.tsx @@ -0,0 +1,46 @@ +import React, { Fragment, 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 { setQueueOption } from 'Store/Actions/queueActions'; +import { CheckInputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; + +function QueueOptions() { + const dispatch = useDispatch(); + const { includeUnknownSeriesItems } = useSelector( + (state: AppState) => state.queue.options + ); + + const handleOptionChange = useCallback( + ({ name, value }: CheckInputChanged) => { + dispatch( + setQueueOption({ + [name]: value, + }) + ); + }, + [dispatch] + ); + + return ( + + + {translate('ShowUnknownSeriesItems')} + + + + + ); +} + +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 523147078..000000000 --- a/frontend/src/Activity/Queue/QueueRow.js +++ /dev/null @@ -1,481 +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 EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; -import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -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, - series, - episode, - languages, - quality, - customFormats, - customFormatScore, - 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 === 'series.sortTitle') { - return ( - - { - series ? - : - title - } - - ); - } - - if (name === 'episode') { - return ( - - { - episode ? - : - '-' - } - - ); - } - - if (name === 'episodes.title') { - return ( - - { - episode ? - : - '-' - } - - ); - } - - if (name === 'episodes.airDateUtc') { - if (episode) { - return ( - - ); - } - - return ( - - - - - ); - } - - 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 && - - } - - { - 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, - series: PropTypes.object, - episode: PropTypes.object, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.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, - 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..25f5cb410 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -0,0 +1,411 @@ +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 EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import useEpisode from 'Episode/useEpisode'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +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; + seriesId?: number; + episodeId?: 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, + seriesId, + episodeId, + 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 series = useSeries(seriesId); + const episode = useEpisode(episodeId, 'episodes'); + 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 = 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 === 'series.sortTitle') { + return ( + + {series ? ( + + ) : ( + title + )} + + ); + } + + if (name === 'episode') { + return ( + + {episode ? ( + + ) : ( + '-' + )} + + ); + } + + if (name === 'episodes.title') { + return ( + + {series && episode ? ( + + ) : ( + '-' + )} + + ); + } + + if (name === 'episodes.airDateUtc') { + if (episode) { + return ; + } + + return -; + } + + 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 e1e469a70..000000000 --- a/frontend/src/Activity/Queue/QueueRowConnector.js +++ /dev/null @@ -1,70 +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 createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import QueueRow from './QueueRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - createEpisodeSelector(), - createUISettingsSelector(), - (series, episode, uiSettings) => { - const result = { - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - - result.series = series; - result.episode = episode; - - 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, - episode: 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 63% rename from frontend/src/Activity/Queue/QueueStatus.js rename to frontend/src/Activity/Queue/QueueStatus.tsx index f7cab31ca..d7314baff 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,23 @@ 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 + 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 9412d7952..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.includeUnknownSeriesItems, - (app, status, includeUnknownSeriesItems) => { - const { - errors, - warnings, - unknownErrors, - unknownWarnings, - count, - totalCount - } = status.item; - - return { - isConnected: app.isConnected, - isReconnecting: app.isReconnecting, - isPopulated: status.isPopulated, - ...status.item, - count: includeUnknownSeriesItems ? totalCount : count, - errors: includeUnknownSeriesItems ? errors || unknownErrors : errors, - warnings: includeUnknownSeriesItems ? 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..4fd37b28c --- /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.includeUnknownSeriesItems, + (isPopulated, status, includeUnknownSeriesItems) => { + const { + errors, + warnings, + unknownErrors, + unknownWarnings, + count, + totalCount, + } = status; + + return { + ...status, + isPopulated, + count: includeUnknownSeriesItems ? totalCount : count, + errors: includeUnknownSeriesItems ? errors || unknownErrors : errors, + warnings: includeUnknownSeriesItems + ? 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 4a8330a6c..a5bb0b33c 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 History from 'Activity/History/History'; -import QueueConnector from 'Activity/Queue/QueueConnector'; +import Queue from 'Activity/Queue/Queue'; import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; @@ -130,7 +130,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: { + includeUnknownSeriesItems: boolean; + }; } export default QueueAppState; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 614281c26..bf618a87d 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'; @@ -50,7 +50,7 @@ const links = [ { title: () => translate('Queue'), to: '/activity/queue', - statusComponent: QueueStatusConnector + statusComponent: QueueStatus }, { title: () => translate('History'), diff --git a/frontend/src/Episode/useEpisode.ts b/frontend/src/Episode/useEpisode.ts index 4f32ffe7f..7f80ae1b3 100644 --- a/frontend/src/Episode/useEpisode.ts +++ b/frontend/src/Episode/useEpisode.ts @@ -9,7 +9,7 @@ export type EpisodeEntities = | 'cutoffUnmet' | 'missing'; -function createEpisodeSelector(episodeId: number) { +function createEpisodeSelector(episodeId?: number) { return createSelector( (state: AppState) => state.episodes.items, (episodes) => { @@ -18,7 +18,7 @@ function createEpisodeSelector(episodeId: number) { ); } -function createCalendarEpisodeSelector(episodeId: number) { +function createCalendarEpisodeSelector(episodeId?: number) { return createSelector( (state: AppState) => state.calendar.items, (episodes) => { @@ -27,7 +27,10 @@ function createCalendarEpisodeSelector(episodeId: number) { ); } -function useEpisode(episodeId: number, episodeEntity: EpisodeEntities) { +function useEpisode( + episodeId: number | undefined, + episodeEntity: EpisodeEntities +) { let selector = createEpisodeSelector; switch (episodeEntity) { 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/Series/Index/Overview/SeriesIndexOverviewInfo.tsx b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.tsx index 6120c86af..58a98c86d 100644 --- a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.tsx +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.tsx @@ -137,14 +137,13 @@ function getInfoRowProps( date: formatDateTime(previousAiring, longDateFormat, timeFormat), }), iconName: icons.CALENDAR, - label: - getRelativeDate({ - date: previousAiring, - shortDateFormat, - showRelativeDates, - timeFormat, - timeForToday: true, - }) ?? '', + label: getRelativeDate({ + date: previousAiring, + shortDateFormat, + showRelativeDates, + timeFormat, + timeForToday: true, + }), }; } diff --git a/frontend/src/Series/Index/Table/SeasonsCell.tsx b/frontend/src/Series/Index/Table/SeasonsCell.tsx index fdf12c2ed..1078a622d 100644 --- a/frontend/src/Series/Index/Table/SeasonsCell.tsx +++ b/frontend/src/Series/Index/Table/SeasonsCell.tsx @@ -1,7 +1,6 @@ import React from 'react'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import Popover from 'Components/Tooltip/Popover'; -import TooltipPosition from 'Helpers/Props/TooltipPosition'; import SeasonDetails from 'Series/Index/Select/SeasonPass/SeasonDetails'; import { Season } from 'Series/Series'; import translate from 'Utilities/String/translate'; @@ -33,7 +32,7 @@ function SeasonsCell(props: SeriesStatusCellProps) { anchor={seasonCount} title={translate('SeasonDetails')} body={} - position={TooltipPosition.Left} + position="left" /> ) : ( seasonCount diff --git a/frontend/src/Utilities/Date/getRelativeDate.tsx b/frontend/src/Utilities/Date/getRelativeDate.ts similarity index 99% rename from frontend/src/Utilities/Date/getRelativeDate.tsx rename to frontend/src/Utilities/Date/getRelativeDate.ts index 3a7f4f143..509f1f39d 100644 --- a/frontend/src/Utilities/Date/getRelativeDate.tsx +++ b/frontend/src/Utilities/Date/getRelativeDate.ts @@ -27,7 +27,7 @@ function getRelativeDate({ includeTime = false, }: GetRelativeDateOptions) { if (!date) { - return null; + return ''; } if ((includeTime || timeForToday) && !timeFormat) { diff --git a/frontend/src/typings/Queue.ts b/frontend/src/typings/Queue.ts index 8fe114489..dd266d132 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,13 +38,14 @@ interface Queue extends ModelBase { statusMessages: StatusMessage[]; errorMessage: string; downloadId: string; - protocol: string; + protocol: DownloadProtocol; downloadClient: string; outputPath: string; episodeHasFile: boolean; seriesId?: number; episodeId?: number; seasonNumber?: number; + downloadClientHasPostImportCategory: boolean; } export default Queue;