diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.tsx b/frontend/src/Activity/Blocklist/BlocklistRow.tsx index 58d75b1dd..c7410320d 100644 --- a/frontend/src/Activity/Blocklist/BlocklistRow.tsx +++ b/frontend/src/Activity/Blocklist/BlocklistRow.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +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'; @@ -119,7 +119,7 @@ function BlocklistRow(props: BlocklistRowProps) { if (name === 'date') { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ts(2739) - return ; + return ; } if (name === 'indexer') { diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js deleted file mode 100644 index 862d8707e..000000000 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ /dev/null @@ -1,354 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; -import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; -import Link from 'Components/Link/Link'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; -import translate from 'Utilities/String/translate'; -import styles from './HistoryDetails.css'; - -function HistoryDetails(props) { - const { - eventType, - sourceTitle, - data, - downloadId, - shortDateFormat, - timeFormat - } = props; - - if (eventType === 'grabbed') { - const { - indexer, - releaseGroup, - seriesMatchType, - customFormatScore, - nzbInfoUrl, - downloadClient, - downloadClientName, - age, - ageHours, - ageMinutes, - publishedDate - } = data; - - const downloadClientNameInfo = downloadClientName ?? downloadClient; - - return ( - - - - { - indexer ? - : - null - } - - { - releaseGroup ? - : - null - } - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - { - seriesMatchType ? - : - null - } - - { - nzbInfoUrl ? - - - {translate('InfoUrl')} - - - - {nzbInfoUrl} - - : - null - } - - { - downloadClientNameInfo ? - : - null - } - - { - downloadId ? - : - null - } - - { - age || ageHours || ageMinutes ? - : - null - } - - { - publishedDate ? - : - null - } - - ); - } - - if (eventType === 'downloadFailed') { - const { - message - } = data; - - return ( - - - - { - downloadId ? - : - null - } - - { - message ? - : - null - } - - ); - } - - if (eventType === 'downloadFolderImported') { - const { - customFormatScore, - droppedPath, - importedPath - } = data; - - return ( - - - - { - droppedPath ? - : - null - } - - { - importedPath ? - : - null - } - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - ); - } - - if (eventType === 'episodeFileDeleted') { - const { - reason, - customFormatScore - } = data; - - let reasonMessage = ''; - - switch (reason) { - case 'Manual': - reasonMessage = translate('DeletedReasonManual'); - break; - case 'MissingFromDisk': - reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk'); - break; - case 'Upgrade': - reasonMessage = translate('DeletedReasonUpgrade'); - break; - default: - reasonMessage = ''; - } - - return ( - - - - - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - ); - } - - if (eventType === 'episodeFileRenamed') { - const { - sourcePath, - sourceRelativePath, - path, - relativePath - } = data; - - return ( - - - - - - - - - - ); - } - - if (eventType === 'downloadIgnored') { - const { - message - } = data; - - return ( - - - - { - downloadId ? - : - null - } - - { - message ? - : - null - } - - ); - } - - return ( - - - - ); -} - -HistoryDetails.propTypes = { - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetails.tsx b/frontend/src/Activity/History/Details/HistoryDetails.tsx new file mode 100644 index 000000000..d4c8f9f4f --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.tsx @@ -0,0 +1,289 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import Link from 'Components/Link/Link'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { + DownloadFailedHistory, + DownloadFolderImportedHistory, + DownloadIgnoredHistory, + EpisodeFileDeletedHistory, + EpisodeFileRenamedHistory, + GrabbedHistoryData, + HistoryData, + HistoryEventType, +} from 'typings/History'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import styles from './HistoryDetails.css'; + +interface HistoryDetailsProps { + eventType: HistoryEventType; + sourceTitle: string; + data: HistoryData; + downloadId?: string; + shortDateFormat: string; + timeFormat: string; +} + +function HistoryDetails(props: HistoryDetailsProps) { + const { eventType, sourceTitle, data, downloadId } = props; + + const { shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + if (eventType === 'grabbed') { + const { + indexer, + releaseGroup, + seriesMatchType, + customFormatScore, + nzbInfoUrl, + downloadClient, + downloadClientName, + age, + ageHours, + ageMinutes, + publishedDate, + } = data as GrabbedHistoryData; + + const downloadClientNameInfo = downloadClientName ?? downloadClient; + + return ( + + + + {indexer ? ( + + ) : null} + + {releaseGroup ? ( + + ) : null} + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + {seriesMatchType ? ( + + ) : null} + + {nzbInfoUrl ? ( + + + {translate('InfoUrl')} + + + + {nzbInfoUrl} + + + ) : null} + + {downloadClientNameInfo ? ( + + ) : null} + + {downloadId ? ( + + ) : null} + + {age || ageHours || ageMinutes ? ( + + ) : null} + + {publishedDate ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFailed') { + const { message } = data as DownloadFailedHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFolderImported') { + const { customFormatScore, droppedPath, importedPath } = + data as DownloadFolderImportedHistory; + + return ( + + + + {droppedPath ? ( + + ) : null} + + {importedPath ? ( + + ) : null} + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + ); + } + + if (eventType === 'episodeFileDeleted') { + const { reason, customFormatScore } = data as EpisodeFileDeletedHistory; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = translate('DeletedReasonManual'); + break; + case 'MissingFromDisk': + reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk'); + break; + case 'Upgrade': + reasonMessage = translate('DeletedReasonUpgrade'); + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + ); + } + + if (eventType === 'episodeFileRenamed') { + const { sourcePath, sourceRelativePath, path, relativePath } = + data as EpisodeFileRenamedHistory; + + return ( + + + + + + + + + + ); + } + + if (eventType === 'downloadIgnored') { + const { message } = data as DownloadIgnoredHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + return ( + + + + ); +} + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js deleted file mode 100644 index 0848c7905..000000000 --- a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js +++ /dev/null @@ -1,19 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import HistoryDetails from './HistoryDetails'; - -function createMapStateToProps() { - return createSelector( - createUISettingsSelector(), - (uiSettings) => { - return _.pick(uiSettings, [ - 'shortDateFormat', - 'timeFormat' - ]); - } - ); -} - -export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx similarity index 58% rename from frontend/src/Activity/History/Details/HistoryDetailsModal.js rename to frontend/src/Activity/History/Details/HistoryDetailsModal.tsx index ddeea5b78..a833bca5b 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -8,11 +7,12 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; +import { HistoryData, HistoryEventType } from 'typings/History'; import translate from 'Utilities/String/translate'; import HistoryDetails from './HistoryDetails'; import styles from './HistoryDetailsModal.css'; -function getHeaderTitle(eventType) { +function getHeaderTitle(eventType: HistoryEventType) { switch (eventType) { case 'grabbed': return translate('Grabbed'); @@ -31,7 +31,20 @@ function getHeaderTitle(eventType) { } } -function HistoryDetailsModal(props) { +interface HistoryDetailsModalProps { + isOpen: boolean; + eventType: HistoryEventType; + sourceTitle: string; + data: HistoryData; + downloadId?: string; + isMarkingAsFailed: boolean; + shortDateFormat: string; + timeFormat: string; + onMarkAsFailedPress: () => void; + onModalClose: () => void; +} + +function HistoryDetailsModal(props: HistoryDetailsModalProps) { const { isOpen, eventType, @@ -42,18 +55,13 @@ function HistoryDetailsModal(props) { shortDateFormat, timeFormat, onMarkAsFailedPress, - onModalClose + onModalClose, } = props; return ( - + - - {getHeaderTitle(eventType)} - + {getHeaderTitle(eventType)} - { - eventType === 'grabbed' && - - {translate('MarkAsFailed')} - - } + {eventType === 'grabbed' && ( + + {translate('MarkAsFailed')} + + )} - + ); } -HistoryDetailsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - isMarkingAsFailed: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - HistoryDetailsModal.defaultProps = { - isMarkingAsFailed: false + isMarkingAsFailed: false, }; export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js deleted file mode 100644 index e5cc31ecd..000000000 --- a/frontend/src/Activity/History/History.js +++ /dev/null @@ -1,180 +0,0 @@ -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 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 hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import translate from 'Utilities/String/translate'; -import HistoryFilterModal from './HistoryFilterModal'; -import HistoryRowConnector from './HistoryRowConnector'; - -class History extends Component { - - // - // Lifecycle - - shouldComponentUpdate(nextProps) { - // Don't update when fetching has completed if items have changed, - // before episodes start fetching or when episodes start fetching. - - if ( - ( - this.props.isFetching && - nextProps.isPopulated && - hasDifferentItems(this.props.items, nextProps.items) - ) || - (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching) - ) { - return false; - } - - return true; - } - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - columns, - selectedFilterKey, - filters, - customFilters, - totalRecords, - isEpisodesFetching, - isEpisodesPopulated, - episodesError, - onFilterSelect, - onFirstPagePress, - ...otherProps - } = this.props; - - const isFetchingAny = isFetching || isEpisodesFetching; - const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); - const hasError = error || episodesError; - - return ( - - - - - - - - - - - - - - - - - { - isFetchingAny && !isAllPopulated && - - } - - { - !isFetchingAny && hasError && - - {translate('HistoryLoadError')} - - } - - { - // If history isPopulated and it's empty show no history found and don't - // wait for the episodes to populate because they are never coming. - - isPopulated && !hasError && !items.length && - - {translate('NoHistoryFound')} - - } - - { - isAllPopulated && !hasError && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
- } -
-
- ); - } -} - -History.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: 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, - totalRecords: PropTypes.number, - isEpisodesFetching: PropTypes.bool.isRequired, - isEpisodesPopulated: PropTypes.bool.isRequired, - episodesError: PropTypes.object, - onFilterSelect: PropTypes.func.isRequired, - onFirstPagePress: PropTypes.func.isRequired -}; - -export default History; diff --git a/frontend/src/Activity/History/History.tsx b/frontend/src/Activity/History/History.tsx new file mode 100644 index 000000000..1020d90ea --- /dev/null +++ b/frontend/src/Activity/History/History.tsx @@ -0,0 +1,228 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +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 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 { align, icons, kinds } from 'Helpers/Props'; +import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; +import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { + clearHistory, + fetchHistory, + gotoHistoryPage, + setHistoryFilter, + setHistorySort, + setHistoryTableOption, +} from 'Store/Actions/historyActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import HistoryItem from 'typings/History'; +import { TableOptionsChangePayload } from 'typings/Table'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import HistoryFilterModal from './HistoryFilterModal'; +import HistoryRow from './HistoryRow'; + +function History() { + const requestCurrentPage = useCurrentPage(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + totalPages, + totalRecords, + } = useSelector((state: AppState) => state.history); + + const { isEpisodesFetching, isEpisodesPopulated, episodesError } = + useSelector(createEpisodesFetchingSelector()); + const customFilters = useSelector(createCustomFiltersSelector('history')); + const dispatch = useDispatch(); + + const isFetchingAny = isFetching || isEpisodesFetching; + const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); + const hasError = error || episodesError; + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoHistoryPage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setHistoryFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setHistorySort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setHistoryTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoHistoryPage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchHistory()); + } else { + dispatch(gotoHistoryPage({ page: 1 })); + } + + return () => { + dispatch(clearHistory()); + dispatch(clearEpisodes()); + dispatch(clearEpisodeFiles()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const episodeIds = selectUniqueIds(items, 'episodeId'); + + if (episodeIds.length) { + dispatch(fetchEpisodes({ episodeIds })); + } else { + dispatch(clearEpisodes()); + } + }, [items, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchHistory()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + return ( + + + + + + + + + + + + + + + + + {isFetchingAny && !isAllPopulated ? : null} + + {!isFetchingAny && hasError ? ( + {translate('HistoryLoadError')} + ) : null} + + { + // If history isPopulated and it's empty show no history found and don't + // wait for the episodes to populate because they are never coming. + + isPopulated && !hasError && !items.length ? ( + {translate('NoHistoryFound')} + ) : null + } + + {isAllPopulated && !hasError && items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ + +
+ ) : null} +
+
+ ); +} + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js deleted file mode 100644 index b407960bd..000000000 --- a/frontend/src/Activity/History/HistoryConnector.js +++ /dev/null @@ -1,165 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import withCurrentPage from 'Components/withCurrentPage'; -import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; -import * as historyActions from 'Store/Actions/historyActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import History from './History'; - -function createMapStateToProps() { - return createSelector( - (state) => state.history, - (state) => state.episodes, - createCustomFiltersSelector('history'), - (history, episodes, customFilters) => { - return { - isEpisodesFetching: episodes.isFetching, - isEpisodesPopulated: episodes.isPopulated, - episodesError: episodes.error, - customFilters, - ...history - }; - } - ); -} - -const mapDispatchToProps = { - ...historyActions, - fetchEpisodes, - clearEpisodes, - clearEpisodeFiles -}; - -class HistoryConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchHistory, - gotoHistoryFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchHistory(); - } else { - gotoHistoryFirstPage(); - } - } - - 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(); - } - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearHistory(); - this.props.clearEpisodes(); - this.props.clearEpisodeFiles(); - } - - // - // Control - - repopulate = () => { - this.props.fetchHistory(); - }; - - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoHistoryFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoHistoryPreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoHistoryNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoHistoryLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoHistoryPage({ page }); - }; - - onSortPress = (sortKey) => { - this.props.setHistorySort({ sortKey }); - }; - - onFilterSelect = (selectedFilterKey) => { - this.props.setHistoryFilter({ selectedFilterKey }); - }; - - onTableOptionChange = (payload) => { - this.props.setHistoryTableOption(payload); - - if (payload.pageSize) { - this.props.gotoHistoryFirstPage(); - } - }; - - // - // Render - - render() { - return ( - - ); - } -} - -HistoryConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchHistory: PropTypes.func.isRequired, - gotoHistoryFirstPage: PropTypes.func.isRequired, - gotoHistoryPreviousPage: PropTypes.func.isRequired, - gotoHistoryNextPage: PropTypes.func.isRequired, - gotoHistoryLastPage: PropTypes.func.isRequired, - gotoHistoryPage: PropTypes.func.isRequired, - setHistorySort: PropTypes.func.isRequired, - setHistoryFilter: PropTypes.func.isRequired, - setHistoryTableOption: PropTypes.func.isRequired, - clearHistory: PropTypes.func.isRequired, - fetchEpisodes: PropTypes.func.isRequired, - clearEpisodes: PropTypes.func.isRequired, - clearEpisodeFiles: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) -); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.tsx similarity index 60% rename from frontend/src/Activity/History/HistoryEventTypeCell.js rename to frontend/src/Activity/History/HistoryEventTypeCell.tsx index 2f5ef6ee1..adedf08c0 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.tsx @@ -1,12 +1,17 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons, kinds } from 'Helpers/Props'; +import { + EpisodeFileDeletedHistory, + GrabbedHistoryData, + HistoryData, + HistoryEventType, +} from 'typings/History'; import translate from 'Utilities/String/translate'; import styles from './HistoryEventTypeCell.css'; -function getIconName(eventType, data) { +function getIconName(eventType: HistoryEventType, data: HistoryData) { switch (eventType) { case 'grabbed': return icons.DOWNLOADING; @@ -17,7 +22,9 @@ function getIconName(eventType, data) { case 'downloadFailed': return icons.DOWNLOADING; case 'episodeFileDeleted': - return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE; + return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' + ? icons.FILE_MISSING + : icons.DELETE; case 'episodeFileRenamed': return icons.ORGANIZE; case 'downloadIgnored': @@ -27,7 +34,7 @@ function getIconName(eventType, data) { } } -function getIconKind(eventType) { +function getIconKind(eventType: HistoryEventType) { switch (eventType) { case 'downloadFailed': return kinds.DANGER; @@ -36,10 +43,13 @@ function getIconKind(eventType) { } } -function getTooltip(eventType, data) { +function getTooltip(eventType: HistoryEventType, data: HistoryData) { switch (eventType) { case 'grabbed': - return translate('EpisodeGrabbedTooltip', { indexer: data.indexer, downloadClient: data.downloadClient }); + return translate('EpisodeGrabbedTooltip', { + indexer: (data as GrabbedHistoryData).indexer, + downloadClient: (data as GrabbedHistoryData).downloadClient, + }); case 'seriesFolderImported': return translate('SeriesFolderImportedTooltip'); case 'downloadFolderImported': @@ -47,7 +57,9 @@ function getTooltip(eventType, data) { case 'downloadFailed': return translate('DownloadFailedEpisodeTooltip'); case 'episodeFileDeleted': - return data.reason === 'MissingFromDisk' ? translate('EpisodeFileMissingTooltip') : translate('EpisodeFileDeletedTooltip'); + return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' + ? translate('EpisodeFileMissingTooltip') + : translate('EpisodeFileDeletedTooltip'); case 'episodeFileRenamed': return translate('EpisodeFileRenamedTooltip'); case 'downloadIgnored': @@ -57,31 +69,21 @@ function getTooltip(eventType, data) { } } -function HistoryEventTypeCell({ eventType, data }) { +interface HistoryEventTypeCellProps { + eventType: HistoryEventType; + data: HistoryData; +} + +function HistoryEventTypeCell({ eventType, data }: HistoryEventTypeCellProps) { const iconName = getIconName(eventType, data); const iconKind = getIconKind(eventType); const tooltip = getTooltip(eventType, data); return ( - - + + ); } -HistoryEventTypeCell.propTypes = { - eventType: PropTypes.string.isRequired, - data: PropTypes.object -}; - -HistoryEventTypeCell.defaultProps = { - data: {} -}; - export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js deleted file mode 100644 index 507fdc2d7..000000000 --- a/frontend/src/Activity/History/HistoryRow.js +++ /dev/null @@ -1,312 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import episodeEntities from 'Episode/episodeEntities'; -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, tooltipPositions } from 'Helpers/Props'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; -import HistoryDetailsModal from './Details/HistoryDetailsModal'; -import HistoryEventTypeCell from './HistoryEventTypeCell'; -import styles from './HistoryRow.css'; - -class HistoryRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.isMarkingAsFailed && - !this.props.isMarkingAsFailed && - !this.props.markAsFailedError - ) { - this.setState({ isDetailsModalOpen: false }); - } - } - - // - // Listeners - - onDetailsPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - episodeId, - series, - episode, - languages, - quality, - customFormats, - customFormatScore, - qualityCutoffNotMet, - eventType, - sourceTitle, - date, - data, - downloadId, - isMarkingAsFailed, - columns, - shortDateFormat, - timeFormat, - onMarkAsFailedPress - } = this.props; - - if (!series || !episode) { - return null; - } - - return ( - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'eventType') { - return ( - - ); - } - - if (name === 'series.sortTitle') { - return ( - - - - ); - } - - if (name === 'episode') { - return ( - - - - ); - } - - if (name === 'episodes.title') { - return ( - - - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'date') { - return ( - - ); - } - - if (name === 'downloadClient') { - return ( - - {data.downloadClient} - - ); - } - - if (name === 'indexer') { - return ( - - {data.indexer} - - ); - } - - if (name === 'customFormatScore') { - return ( - - } - position={tooltipPositions.BOTTOM} - /> - - ); - } - - if (name === 'releaseGroup') { - return ( - - {data.releaseGroup} - - ); - } - - if (name === 'sourceTitle') { - return ( - - {sourceTitle} - - ); - } - - if (name === 'details') { - return ( - -
- -
-
- ); - } - - return null; - }) - } - - -
- ); - } - -} - -HistoryRow.propTypes = { - episodeId: PropTypes.number, - series: PropTypes.object.isRequired, - episode: PropTypes.object, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.isRequired, - qualityCutoffNotMet: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - downloadId: PropTypes.string, - isMarkingAsFailed: PropTypes.bool, - markAsFailedError: PropTypes.object, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired -}; - -HistoryRow.defaultProps = { - customFormats: [] -}; - -export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRow.tsx b/frontend/src/Activity/History/HistoryRow.tsx new file mode 100644 index 000000000..bcd84f606 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.tsx @@ -0,0 +1,275 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import episodeEntities from 'Episode/episodeEntities'; +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 usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import { QualityModel } from 'Quality/Quality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CustomFormat from 'typings/CustomFormat'; +import { HistoryData, HistoryEventType } from 'typings/History'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; +import styles from './HistoryRow.css'; + +interface HistoryRowProps { + id: number; + episodeId: number; + seriesId: number; + languages: object[]; + quality: QualityModel; + customFormats?: CustomFormat[]; + customFormatScore: number; + qualityCutoffNotMet: boolean; + eventType: HistoryEventType; + sourceTitle: string; + date: string; + data: HistoryData; + downloadId?: string; + isMarkingAsFailed?: boolean; + markAsFailedError?: object; + columns: Column[]; +} + +function HistoryRow(props: HistoryRowProps) { + const { + id, + episodeId, + seriesId, + languages, + quality, + customFormats = [], + customFormatScore, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + downloadId, + isMarkingAsFailed, + markAsFailedError, + columns, + } = props; + + const wasMarkingAsFailed = usePrevious(isMarkingAsFailed); + const dispatch = useDispatch(); + const series = useSeries(seriesId); + const episode = useEpisode(episodeId, 'episodes'); + + const { shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handleDetailsPress = useCallback(() => { + setIsDetailsModalOpen(true); + }, [setIsDetailsModalOpen]); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, [setIsDetailsModalOpen]); + + const handleMarkAsFailedPress = useCallback(() => { + dispatch(markAsFailed({ id })); + }, [id, dispatch]); + + useEffect(() => { + if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) { + setIsDetailsModalOpen(false); + dispatch(fetchHistory()); + } + }, [ + wasMarkingAsFailed, + isMarkingAsFailed, + markAsFailedError, + setIsDetailsModalOpen, + dispatch, + ]); + + if (!series || !episode) { + return null; + } + + return ( + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodes.title') { + return ( + + + + ); + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'date') { + return ; + } + + if (name === 'downloadClient') { + return ( + + {'downloadClient' in data ? data.downloadClient : ''} + + ); + } + + if (name === 'indexer') { + return ( + + {'indexer' in data ? data.indexer : ''} + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + + if (name === 'releaseGroup') { + return ( + + {'releaseGroup' in data ? data.releaseGroup : ''} + + ); + } + + if (name === 'sourceTitle') { + return {sourceTitle}; + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + })} + + + + ); +} + +HistoryRow.defaultProps = { + customFormats: [], +}; + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js deleted file mode 100644 index b5d6223f6..000000000 --- a/frontend/src/Activity/History/HistoryRowConnector.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 { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; -import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import HistoryRow from './HistoryRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - createEpisodeSelector(), - createUISettingsSelector(), - (series, episode, uiSettings) => { - return { - series, - episode, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -const mapDispatchToProps = { - fetchHistory, - markAsFailed -}; - -class HistoryRowConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - if ( - prevProps.isMarkingAsFailed && - !this.props.isMarkingAsFailed && - !this.props.markAsFailedError - ) { - this.props.fetchHistory(); - } - } - - // - // Listeners - - onMarkAsFailedPress = () => { - this.props.markAsFailed({ id: this.props.id }); - }; - - // - // Render - - render() { - return ( - - ); - } - -} - -HistoryRowConnector.propTypes = { - id: PropTypes.number.isRequired, - isMarkingAsFailed: PropTypes.bool, - markAsFailedError: PropTypes.object, - fetchHistory: PropTypes.func.isRequired, - markAsFailed: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector); diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index f143ace3f..523147078 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -4,7 +4,7 @@ import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import ProgressBar from 'Components/ProgressBar'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +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'; @@ -217,7 +217,7 @@ class QueueRow extends Component { if (name === 'episodes.airDateUtc') { if (episode) { return ( - @@ -366,7 +366,7 @@ class QueueRow extends Component { if (name === 'added') { return ( - diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index e7f8a37ff..4a8330a6c 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { Redirect, Route } from 'react-router-dom'; import Blocklist from 'Activity/Blocklist/Blocklist'; -import HistoryConnector from 'Activity/History/HistoryConnector'; +import History from 'Activity/History/History'; import QueueConnector from 'Activity/Queue/QueueConnector'; import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; @@ -125,7 +125,7 @@ function AppRoutes(props) { , - AppSectionFilterState {} + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState {} export default HistoryAppState; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js deleted file mode 100644 index 37d23e8f9..000000000 --- a/frontend/src/Components/Table/Cells/RelativeDateCell.js +++ /dev/null @@ -1,69 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import TableRowCell from './TableRowCell'; -import styles from './RelativeDateCell.css'; - -class RelativeDateCell extends PureComponent { - - // - // Render - - render() { - const { - className, - date, - includeSeconds, - includeTime, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - component: Component, - dispatch, - ...otherProps - } = this.props; - - if (!date) { - return ( - - ); - } - - return ( - - {getRelativeDate({ date, shortDateFormat, showRelativeDates, timeFormat, includeSeconds, includeTime, timeForToday: true })} - - ); - } -} - -RelativeDateCell.propTypes = { - className: PropTypes.string.isRequired, - date: PropTypes.string, - includeSeconds: PropTypes.bool.isRequired, - includeTime: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - component: PropTypes.elementType, - dispatch: PropTypes.func -}; - -RelativeDateCell.defaultProps = { - className: styles.cell, - includeSeconds: false, - includeTime: false, - component: TableRowCell -}; - -export default RelativeDateCell; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.tsx b/frontend/src/Components/Table/Cells/RelativeDateCell.tsx new file mode 100644 index 000000000..1c5be48be --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import TableRowCell from './TableRowCell'; +import styles from './RelativeDateCell.css'; + +interface RelativeDateCellProps { + className?: string; + date?: string; + includeSeconds?: boolean; + includeTime?: boolean; + component?: React.ElementType; +} + +function RelativeDateCell(props: RelativeDateCellProps) { + const { + className = styles.cell, + date, + includeSeconds = false, + includeTime = false, + + component: Component = TableRowCell, + ...otherProps + } = props; + + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + + if (!date) { + return ; + } + + return ( + + {getRelativeDate({ + date, + shortDateFormat, + showRelativeDates, + timeFormat, + includeSeconds, + includeTime, + timeForToday: true, + })} + + ); +} + +export default RelativeDateCell; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js deleted file mode 100644 index ed996abbe..000000000 --- a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js +++ /dev/null @@ -1,21 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import RelativeDateCell from './RelativeDateCell'; - -function createMapStateToProps() { - return createSelector( - createUISettingsSelector(), - (uiSettings) => { - return _.pick(uiSettings, [ - 'showRelativeDates', - 'shortDateFormat', - 'longDateFormat', - 'timeFormat' - ]); - } - ); -} - -export default connect(createMapStateToProps, null)(RelativeDateCell); diff --git a/frontend/src/Episode/EpisodeNumber.js b/frontend/src/Episode/EpisodeNumber.js deleted file mode 100644 index 7f5c448f1..000000000 --- a/frontend/src/Episode/EpisodeNumber.js +++ /dev/null @@ -1,143 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Fragment } from 'react'; -import Icon from 'Components/Icon'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import padNumber from 'Utilities/Number/padNumber'; -import filterAlternateTitles from 'Utilities/Series/filterAlternateTitles'; -import translate from 'Utilities/String/translate'; -import SceneInfo from './SceneInfo'; -import styles from './EpisodeNumber.css'; - -function getWarningMessage(unverifiedSceneNumbering, seriesType, absoluteEpisodeNumber) { - const messages = []; - - if (unverifiedSceneNumbering) { - messages.push(translate('SceneNumberNotVerified')); - } - - if (seriesType === 'anime' && !absoluteEpisodeNumber) { - messages.push(translate('EpisodeMissingAbsoluteNumber')); - } - - return messages.join('\n'); -} - -function EpisodeNumber(props) { - const { - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - sceneSeasonNumber, - sceneEpisodeNumber, - sceneAbsoluteEpisodeNumber, - useSceneNumbering, - unverifiedSceneNumbering, - alternateTitles: seriesAlternateTitles, - seriesType, - showSeasonNumber - } = props; - - const alternateTitles = filterAlternateTitles(seriesAlternateTitles, null, useSceneNumbering, seasonNumber, sceneSeasonNumber); - - const hasSceneInformation = sceneSeasonNumber !== undefined || - sceneEpisodeNumber !== undefined || - (seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) || - !!alternateTitles.length; - - const warningMessage = getWarningMessage(unverifiedSceneNumbering, seriesType, absoluteEpisodeNumber); - - return ( - - { - hasSceneInformation ? - - { - showSeasonNumber && seasonNumber != null && - - {seasonNumber}x - - } - - {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} - - { - seriesType === 'anime' && !!absoluteEpisodeNumber && - - ({absoluteEpisodeNumber}) - - } - - } - title={translate('SceneInformation')} - body={ - - } - position={tooltipPositions.RIGHT} - /> : - - { - showSeasonNumber && seasonNumber != null && - - {seasonNumber}x - - } - - {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} - - { - seriesType === 'anime' && !!absoluteEpisodeNumber && - - ({absoluteEpisodeNumber}) - - } - - } - - { - warningMessage ? - : - null - } - - - ); -} - -EpisodeNumber.propTypes = { - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - sceneSeasonNumber: PropTypes.number, - sceneEpisodeNumber: PropTypes.number, - sceneAbsoluteEpisodeNumber: PropTypes.number, - useSceneNumbering: PropTypes.bool.isRequired, - unverifiedSceneNumbering: PropTypes.bool.isRequired, - alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, - seriesType: PropTypes.string, - showSeasonNumber: PropTypes.bool.isRequired -}; - -EpisodeNumber.defaultProps = { - useSceneNumbering: false, - unverifiedSceneNumbering: false, - alternateTitles: [], - showSeasonNumber: false -}; - -export default EpisodeNumber; diff --git a/frontend/src/Episode/EpisodeNumber.tsx b/frontend/src/Episode/EpisodeNumber.tsx new file mode 100644 index 000000000..596174499 --- /dev/null +++ b/frontend/src/Episode/EpisodeNumber.tsx @@ -0,0 +1,140 @@ +import React, { Fragment } from 'react'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import { AlternateTitle, SeriesType } from 'Series/Series'; +import padNumber from 'Utilities/Number/padNumber'; +import filterAlternateTitles from 'Utilities/Series/filterAlternateTitles'; +import translate from 'Utilities/String/translate'; +import SceneInfo from './SceneInfo'; +import styles from './EpisodeNumber.css'; + +function getWarningMessage( + unverifiedSceneNumbering: boolean, + seriesType: SeriesType | undefined, + absoluteEpisodeNumber: number | undefined +) { + const messages = []; + + if (unverifiedSceneNumbering) { + messages.push(translate('SceneNumberNotVerified')); + } + + if (seriesType === 'anime' && !absoluteEpisodeNumber) { + messages.push(translate('EpisodeMissingAbsoluteNumber')); + } + + return messages.join('\n'); +} + +export interface EpisodeNumberProps { + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + sceneSeasonNumber?: number; + sceneEpisodeNumber?: number; + sceneAbsoluteEpisodeNumber?: number; + useSceneNumbering?: boolean; + unverifiedSceneNumbering?: boolean; + alternateTitles?: AlternateTitle[]; + seriesType?: SeriesType; + showSeasonNumber?: boolean; +} + +function EpisodeNumber(props: EpisodeNumberProps) { + const { + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + useSceneNumbering = false, + unverifiedSceneNumbering = false, + alternateTitles: seriesAlternateTitles = [], + seriesType, + showSeasonNumber = false, + } = props; + + const alternateTitles = filterAlternateTitles( + seriesAlternateTitles, + null, + useSceneNumbering, + seasonNumber, + sceneSeasonNumber + ); + + const hasSceneInformation = + sceneSeasonNumber !== undefined || + sceneEpisodeNumber !== undefined || + (seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) || + !!alternateTitles.length; + + const warningMessage = getWarningMessage( + unverifiedSceneNumbering, + seriesType, + absoluteEpisodeNumber + ); + + return ( + + {hasSceneInformation ? ( + + {showSeasonNumber && seasonNumber != null && ( + {seasonNumber}x + )} + + {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} + + {seriesType === 'anime' && !!absoluteEpisodeNumber && ( + + ({absoluteEpisodeNumber}) + + )} + + } + title={translate('SceneInformation')} + body={ + + } + position={tooltipPositions.RIGHT} + /> + ) : ( + + {showSeasonNumber && seasonNumber != null && ( + {seasonNumber}x + )} + + {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} + + {seriesType === 'anime' && !!absoluteEpisodeNumber && ( + + ({absoluteEpisodeNumber}) + + )} + + )} + + {warningMessage ? ( + + ) : null} + + ); +} + +export default EpisodeNumber; diff --git a/frontend/src/Episode/EpisodeQuality.js b/frontend/src/Episode/EpisodeQuality.tsx similarity index 69% rename from frontend/src/Episode/EpisodeQuality.js rename to frontend/src/Episode/EpisodeQuality.tsx index 5ca64224a..4de00b0b0 100644 --- a/frontend/src/Episode/EpisodeQuality.js +++ b/frontend/src/Episode/EpisodeQuality.tsx @@ -1,11 +1,15 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Label from 'Components/Label'; import { kinds } from 'Helpers/Props'; +import { QualityModel } from 'Quality/Quality'; import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; -function getTooltip(title, quality, size) { +function getTooltip( + title: string, + quality: QualityModel, + size: number | undefined +) { if (!title) { return; } @@ -27,7 +31,11 @@ function getTooltip(title, quality, size) { return title; } -function revisionLabel(className, quality, showRevision) { +function revisionLabel( + className: string | undefined, + quality: QualityModel, + showRevision: boolean +) { if (!showRevision) { return; } @@ -55,16 +63,27 @@ function revisionLabel(className, quality, showRevision) { ); } + + return null; } -function EpisodeQuality(props) { +interface EpisodeQualityProps { + className?: string; + title?: string; + quality: QualityModel; + size?: number; + isCutoffNotMet?: boolean; + showRevision?: boolean; +} + +function EpisodeQuality(props: EpisodeQualityProps) { const { className, - title, + title = '', quality, size, isCutoffNotMet, - showRevision + showRevision = false, } = props; if (!quality) { @@ -79,23 +98,10 @@ function EpisodeQuality(props) { title={getTooltip(title, quality, size)} > {quality.quality.name} - {revisionLabel(className, quality, showRevision)} + + {revisionLabel(className, quality, showRevision)} ); } -EpisodeQuality.propTypes = { - className: PropTypes.string, - title: PropTypes.string, - quality: PropTypes.object.isRequired, - size: PropTypes.number, - isCutoffNotMet: PropTypes.bool, - showRevision: PropTypes.bool -}; - -EpisodeQuality.defaultProps = { - title: '', - showRevision: false -}; - export default EpisodeQuality; diff --git a/frontend/src/Episode/EpisodeTitleLink.tsx b/frontend/src/Episode/EpisodeTitleLink.tsx index f683820e0..e7455312d 100644 --- a/frontend/src/Episode/EpisodeTitleLink.tsx +++ b/frontend/src/Episode/EpisodeTitleLink.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useState } from 'react'; import Link from 'Components/Link/Link'; import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; @@ -6,8 +5,12 @@ import FinaleType from './FinaleType'; import styles from './EpisodeTitleLink.css'; interface EpisodeTitleLinkProps { + episodeId: number; + seriesId: number; + episodeEntity: string; episodeTitle: string; finaleType?: string; + showOpenSeriesButton: boolean; } function EpisodeTitleLink(props: EpisodeTitleLinkProps) { @@ -38,9 +41,4 @@ function EpisodeTitleLink(props: EpisodeTitleLinkProps) { ); } -EpisodeTitleLink.propTypes = { - episodeTitle: PropTypes.string.isRequired, - finaleType: PropTypes.string, -}; - export default EpisodeTitleLink; diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.js b/frontend/src/Episode/History/EpisodeHistoryRow.js index 93cdb7c26..fd7fea827 100644 --- a/frontend/src/Episode/History/EpisodeHistoryRow.js +++ b/frontend/src/Episode/History/EpisodeHistoryRow.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryDetails from 'Activity/History/Details/HistoryDetails'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import Icon from 'Components/Icon'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; import Popover from 'Components/Tooltip/Popover'; @@ -109,7 +109,7 @@ class EpisodeHistoryRow extends Component { {formatCustomFormatScore(customFormatScore, customFormats.length)}
- {airDate} - ); - } - - return ( - - ); -} - -SeasonEpisodeNumber.propTypes = { - airDate: PropTypes.string, - seriesType: PropTypes.string -}; - -export default SeasonEpisodeNumber; diff --git a/frontend/src/Episode/SeasonEpisodeNumber.tsx b/frontend/src/Episode/SeasonEpisodeNumber.tsx new file mode 100644 index 000000000..c0a0afa51 --- /dev/null +++ b/frontend/src/Episode/SeasonEpisodeNumber.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { SeriesType } from 'Series/Series'; +import EpisodeNumber, { EpisodeNumberProps } from './EpisodeNumber'; + +interface SeasonEpisodeNumberProps extends EpisodeNumberProps { + airDate?: string; + seriesType?: SeriesType; +} + +function SeasonEpisodeNumber(props: SeasonEpisodeNumberProps) { + const { airDate, seriesType, ...otherProps } = props; + + if (seriesType === 'daily' && airDate) { + return {airDate}; + } + + return ( + + ); +} + +export default SeasonEpisodeNumber; diff --git a/frontend/src/Episode/createEpisodesFetchingSelector.ts b/frontend/src/Episode/createEpisodesFetchingSelector.ts new file mode 100644 index 000000000..c1ed3bbc6 --- /dev/null +++ b/frontend/src/Episode/createEpisodesFetchingSelector.ts @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createEpisodesFetchingSelector() { + return createSelector( + (state: AppState) => state.episodes, + (episodes) => { + return { + isEpisodesFetching: episodes.isFetching, + isEpisodesPopulated: episodes.isPopulated, + episodesError: episodes.error, + }; + } + ); +} + +export default createEpisodesFetchingSelector; diff --git a/frontend/src/Episode/useEpisode.ts b/frontend/src/Episode/useEpisode.ts new file mode 100644 index 000000000..4f32ffe7f --- /dev/null +++ b/frontend/src/Episode/useEpisode.ts @@ -0,0 +1,44 @@ +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +export type EpisodeEntities = + | 'calendar' + | 'episodes' + | 'interactiveImport' + | 'cutoffUnmet' + | 'missing'; + +function createEpisodeSelector(episodeId: number) { + return createSelector( + (state: AppState) => state.episodes.items, + (episodes) => { + return episodes.find((e) => e.id === episodeId); + } + ); +} + +function createCalendarEpisodeSelector(episodeId: number) { + return createSelector( + (state: AppState) => state.calendar.items, + (episodes) => { + return episodes.find((e) => e.id === episodeId); + } + ); +} + +function useEpisode(episodeId: number, episodeEntity: EpisodeEntities) { + let selector = createEpisodeSelector; + + switch (episodeEntity) { + case 'calendar': + selector = createCalendarEpisodeSelector; + break; + default: + break; + } + + return useSelector(selector(episodeId)); +} + +export default useEpisode; diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js index 3903f0d71..83c7493c4 100644 --- a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowButton from 'Components/Table/TableRowButton'; import { icons } from 'Helpers/Props'; @@ -41,7 +41,7 @@ class RecentFolderRow extends Component { {folder} - + diff --git a/frontend/src/Series/History/SeriesHistoryRow.js b/frontend/src/Series/History/SeriesHistoryRow.js index 743c8c869..19ec358ee 100644 --- a/frontend/src/Series/History/SeriesHistoryRow.js +++ b/frontend/src/Series/History/SeriesHistoryRow.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryDetails from 'Activity/History/Details/HistoryDetails'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import Icon from 'Components/Icon'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; import Popover from 'Components/Tooltip/Popover'; @@ -133,7 +133,7 @@ class SeriesHistoryRow extends Component { {formatCustomFormatScore(customFormatScore, customFormats.length)} - - diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js index 2de54a189..2c38ea10c 100644 --- a/frontend/src/System/Events/LogsTableRow.js +++ b/frontend/src/System/Events/LogsTableRow.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Icon from 'Components/Icon'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowButton from 'Components/Table/TableRowButton'; import { icons } from 'Helpers/Props'; @@ -98,7 +98,7 @@ class LogsTableRow extends Component { if (name === 'time') { return ( - diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js index ba0339b84..1e5ad552d 100644 --- a/frontend/src/System/Logs/Files/LogFilesTableRow.js +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Link from 'Components/Link/Link'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; import translate from 'Utilities/String/translate'; @@ -23,7 +23,7 @@ class LogFilesTableRow extends Component { {filename} - diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js deleted file mode 100644 index c2c0c17e3..000000000 --- a/frontend/src/Utilities/Object/selectUniqueIds.js +++ /dev/null @@ -1,15 +0,0 @@ -import _ from 'lodash'; - -function selectUniqueIds(items, idProp) { - const ids = _.reduce(items, (result, item) => { - if (item[idProp]) { - result.push(item[idProp]); - } - - return result; - }, []); - - return _.uniq(ids); -} - -export default selectUniqueIds; diff --git a/frontend/src/Utilities/Object/selectUniqueIds.ts b/frontend/src/Utilities/Object/selectUniqueIds.ts new file mode 100644 index 000000000..847613c83 --- /dev/null +++ b/frontend/src/Utilities/Object/selectUniqueIds.ts @@ -0,0 +1,13 @@ +import KeysMatching from 'typings/Helpers/KeysMatching'; + +function selectUniqueIds(items: T[], idProp: KeysMatching) { + return items.reduce((acc: K[], item) => { + if (item[idProp] && acc.indexOf(item[idProp] as K) === -1) { + acc.push(item[idProp] as K); + } + + return acc; + }, []); +} + +export default selectUniqueIds; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js index 7d98eaee3..76fe0e0dd 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +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'; @@ -99,7 +99,7 @@ function CutoffUnmetRow(props) { if (name === 'episodes.airDateUtc') { return ( - diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js index 92ecd451e..7064d9a9a 100644 --- a/frontend/src/Wanted/Missing/MissingRow.js +++ b/frontend/src/Wanted/Missing/MissingRow.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +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'; @@ -102,7 +102,7 @@ function MissingRow(props) { if (name === 'episodes.airDateUtc') { return ( - diff --git a/frontend/src/typings/Helpers/KeysMatching.ts b/frontend/src/typings/Helpers/KeysMatching.ts index 0e20206ef..107e0904f 100644 --- a/frontend/src/typings/Helpers/KeysMatching.ts +++ b/frontend/src/typings/Helpers/KeysMatching.ts @@ -1,4 +1,4 @@ -type KeysMatching = { +export type KeysMatching = { [K in keyof T]-?: T[K] extends V ? K : never; }[keyof T]; diff --git a/frontend/src/typings/History.ts b/frontend/src/typings/History.ts index 99fabe275..d20895f37 100644 --- a/frontend/src/typings/History.ts +++ b/frontend/src/typings/History.ts @@ -11,6 +11,63 @@ export type HistoryEventType = | 'episodeFileRenamed' | 'downloadIgnored'; +export interface GrabbedHistoryData { + indexer: string; + nzbInfoUrl: string; + releaseGroup: string; + age: string; + ageHours: string; + ageMinutes: string; + publishedDate: string; + downloadClient: string; + downloadClientName: string; + size: string; + downloadUrl: string; + guid: string; + tvdbId: string; + tvRageId: string; + protocol: string; + customFormatScore?: string; + seriesMatchType: string; + releaseSource: string; + indexerFlags: string; + releaseType: string; +} + +export interface DownloadFailedHistory { + message: string; +} + +export interface DownloadFolderImportedHistory { + customFormatScore?: string; + droppedPath: string; + importedPath: string; +} + +export interface EpisodeFileDeletedHistory { + customFormatScore?: string; + reason: 'Manual' | 'MissingFromDisk' | 'Upgrade'; +} + +export interface EpisodeFileRenamedHistory { + sourcePath: string; + sourceRelativePath: string; + path: string; + relativePath: string; +} + +export interface DownloadIgnoredHistory { + message: string; +} + +export type HistoryData = + | GrabbedHistoryData + | DownloadFailedHistory + | DownloadFolderImportedHistory + | EpisodeFileDeletedHistory + | EpisodeFileRenamedHistory + | DownloadIgnoredHistory; + export default interface History { episodeId: number; seriesId: number; @@ -23,6 +80,6 @@ export default interface History { date: string; downloadId: string; eventType: HistoryEventType; - data: unknown; + data: HistoryData; id: number; }