Compare commits

..

50 Commits

Author SHA1 Message Date
Bogdan 2f04b037a1 Fixed nlog deprecated calls 2024-08-11 09:08:38 -07:00
Bogdan 7b87de2e93
Clear pending changes for edit import list exclusions on modal close 2024-08-11 11:53:17 -04:00
Bogdan eb2fd13509
Fixed: Overwriting query params for remove item handler (#7075) 2024-08-11 11:51:11 -04:00
Bogdan ffdb08cfe6 Fixed: Dedupe titles to avoid similar search requests 2024-08-11 08:49:22 -07:00
Mark McDowall 37c4647f24 Fix typos and improve log messages 2024-08-11 08:48:33 -07:00
Mark McDowall f7a58aab33 Align queue action buttons on right 2024-08-11 08:48:33 -07:00
Mark McDowall 4b186e894e
Fixed: Marking queued item as failed not blocking the correct Torrent Info Hash 2024-08-11 11:48:22 -04:00
kephasdev 35a2bc9403
Fix: Use indexer's Multi Languages setting for pushed releases
Closes #7059
2024-08-11 11:47:59 -04:00
Bogdan cc03ce04f1 Fixed: Formatting empty size on disk values 2024-08-11 08:46:56 -07:00
Bogdan 363f8fc347
New: Match search releases using IMDb ID if available 2024-08-11 11:46:46 -04:00
RaZaSB 0877a6718d
New: Remove all single quote characters from searches 2024-08-11 11:46:02 -04:00
Bogdan 8b253c36ea
Validation for bulk series editor 2024-08-11 11:45:15 -04:00
Bogdan e6f82270a9
Parse TVDB ID for releases from HDBits
ignore-downstream
2024-08-11 11:45:00 -04:00
Mark McDowall 813965e6a2 New: Configurable log file size limit 2024-08-11 08:44:35 -07:00
Mark McDowall 0d914f4c53 New: Add Compact Log Event Format option for console logging
Closes #7045
2024-08-11 08:44:35 -07:00
Mark McDowall ae7f73208a Upgrade nlog to 5.3.2 2024-08-11 08:44:35 -07:00
Weblate 4c86d673ea Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-08-11 08:44:27 -07:00
Weblate b1527f9abb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: iMohmmedSA <i.mohmmed.i+1@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-31 22:26:09 -07:00
Bogdan 291d792810
Fixed: Moving files on import for usenet clients
Closes #7043
2024-08-01 01:17:10 -04:00
Mark McDowall 9b528eb829
New: Default file log level changed to debug 2024-08-01 01:16:24 -04:00
Mark McDowall 4c0b896174 Improve messaging for for Send Notifications setting in Emby / Jellyfin
Closes #7042
2024-07-31 22:16:01 -07:00
Bogdan 4ff83f9efc
Fixed: Persist Indexer Flags for automatic imports
Revert "Fixed: Persist Indexer Flags when manual importing from queue"

This reverts commit 217611d716.
2024-08-01 01:15:36 -04:00
Bogdan 217611d716
Fixed: Persist Indexer Flags when manual importing from queue 2024-07-31 00:28:01 -04:00
Mark McDowall 1299a97579 Update React Lint rules for TSX 2024-07-30 21:27:33 -07:00
Mark McDowall 4c0de55672 Fixed: Setting page size in Queue, History and Blocklist
Closes #7035
2024-07-30 21:27:33 -07:00
Bogdan 78a0def46a
Fixed: Moving files for torrents when Remove Completed is disabled 2024-07-31 00:27:19 -04:00
Mark McDowall 11a9dcb389
New: Return downloading magnets from Transmission
Closes #7029
2024-07-31 00:26:24 -04:00
Mark McDowall 4eab168267
New: Add metadata links to telegram messages
Closes #5342
---------

Co-authored-by: Ivar Stangeby <istangeby@gmail.com>
2024-07-31 00:25:48 -04:00
Bogdan c9b5a1258a New: Title filter for Series Index 2024-07-30 21:25:10 -07:00
Mark McDowall 9127a91dfc Fixed: Allow leading/trailing spaces on non-Windows
Closes #6971
2024-07-30 21:25:00 -07:00
Weblate cc85a28ff7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Wolfy The Broccoly <theproviderofsolace@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-30 21:24:50 -07:00
Mark McDowall 72db8099e0 Convert System to TypeScript 2024-07-28 17:47:08 -07:00
Weblate ebc5cdb335 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translation: Servarr/Sonarr
2024-07-28 17:27:52 -07:00
Mark McDowall d6d90a64a3 Convert App to TypeScript 2024-07-28 17:27:13 -07:00
Mark McDowall d46f4b2154 Convert Utilities to TypeScript 2024-07-28 17:27:13 -07:00
Mark McDowall 76650af9fd Convert Queue to TypeScript 2024-07-28 16:59:48 -07:00
Mark McDowall 824ed0a369 Convert History to TypeScript 2024-07-28 16:59:48 -07:00
Mark McDowall ee80564dd4 Convert Blocklist to TypeScript 2024-07-28 16:59:48 -07:00
Mark McDowall 3824eff5eb New: Parse Chinese Anime that separates titles with vertical bar
Closes #7014
2024-07-28 16:59:38 -07:00
Bogdan 15e3c3efb1 Include available version in update health check 2024-07-28 16:59:32 -07:00
Stevie Robinson f2f4a98eed Fixed: Interactive Import dropdown width on mobile
Closes #7015
2024-07-28 16:59:21 -07:00
Mark McDowall bc7799139e Don't hash files in development builds 2024-07-28 16:59:21 -07:00
Bogdan 33b62a2def
New: Add TVMaze and TMDB IDs to Kodi .nfo (#7011)
Closes #6895
ignore-downstream
2024-07-28 19:59:10 -04:00
Mark McDowall 5ac6c0e651 Fix height of tags in tag inputs 2024-07-28 16:58:44 -07:00
Bogdan 60cba74c39 Bump ImageSharp to 3.1.5
https://github.com/advisories/GHSA-63p8-c4ww-9cg7
2024-07-28 16:58:39 -07:00
Bogdan 5c2c490cb2 Improve messaging for renamed episode files progress info 2024-07-28 16:58:32 -07:00
Mark McDowall 63fdf8ca8f
Cache root folders and improve getting disk space for series path roots 2024-07-28 19:58:16 -04:00
Mark McDowall e791f4b743
Fixed: Updating series path from different OS paths
Closes #6953
2024-07-28 19:57:54 -04:00
jbstark 6dd85a5af9
New: 'Seasons Monitored Status' Custom Filter to replace 'Has Unmonitored Season'
Closes #6896
2024-07-28 19:57:22 -04:00
Weblate a80f5b794b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-28 16:56:09 -07:00
358 changed files with 6745 additions and 6659 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib"
}

View File

@ -59,7 +59,7 @@ app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media} app_guid=${app_guid:-media}
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory" echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed # Create User / Group as needed
@ -114,7 +114,7 @@ case "$ARCH" in
esac esac
echo "" echo ""
echo "Removing previous tarballs" echo "Removing previous tarballs"
# -f to Force so we fail if it doesnt exist # -f to Force so we fail if it doesn't exist
rm -f "${app^}".*.tar.gz rm -f "${app^}".*.tar.gz
echo "" echo ""
echo "Downloading..." echo "Downloading..."

View File

@ -359,11 +359,16 @@ module.exports = {
], ],
rules: Object.assign(typescriptEslintRecommended.rules, { rules: Object.assign(typescriptEslintRecommended.rules, {
'no-shadow': 'off', '@typescript-eslint/no-unused-vars': [
// These should be enabled after cleaning things up 'error',
'@typescript-eslint/no-unused-vars': 'warn', {
args: 'after-used',
argsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off', 'no-shadow': 'off',
'prettier/prettier': 'error', 'prettier/prettier': 'error',
'simple-import-sort/imports': [ 'simple-import-sort/imports': [
'error', 'error',
@ -376,7 +381,41 @@ module.exports = {
['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$'] ['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$']
] ]
} }
] ],
// React Hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// React
'react/function-component-definition': 'error',
'react/hook-use-state': 'error',
'react/jsx-boolean-value': ['error', 'always'],
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never' }
],
'react/jsx-fragments': 'error',
'react/jsx-handler-names': [
'error',
{
eventHandlerPrefix: 'on',
eventHandlerPropPrefix: 'on'
}
],
'react/jsx-no-bind': ['error', { ignoreRefs: true }],
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }],
'react/jsx-pascal-case': ['error', { allowAllCaps: true }],
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
noSortAlphabetically: true,
reservedFirst: true
}
],
'react/prop-types': 'off',
'react/self-closing-comp': 'error'
}) })
}, },
{ {

View File

@ -67,7 +67,7 @@ module.exports = (env) => {
output: { output: {
path: distFolder, path: distFolder,
publicPath: '/', publicPath: '/',
filename: '[name]-[contenthash].js', filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
sourceMapFilename: '[file].map' sourceMapFilename: '[file].map'
}, },
@ -92,7 +92,7 @@ module.exports = (env) => {
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: 'Content/styles.css', filename: 'Content/styles.css',
chunkFilename: 'Content/[id]-[chunkhash].css' chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css'
}), }),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
@ -202,7 +202,7 @@ module.exports = (env) => {
options: { options: {
importLoaders: 1, importLoaders: 1,
modules: { modules: {
localIdentName: '[name]/[local]/[hash:base64:5]' localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]'
} }
} }
}, },

View File

@ -1,284 +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 ConfirmModal from 'Components/Modal/ConfirmModal';
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 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 BlocklistFilterModal from './BlocklistFilterModal';
import BlocklistRowConnector from './BlocklistRowConnector';
class Blocklist extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isConfirmRemoveModalOpen: false,
isConfirmClearModalOpen: false,
items: props.items
};
}
componentDidUpdate(prevProps) {
const {
items
} = this.props;
if (hasDifferentItems(prevProps.items, items)) {
this.setState((state) => {
return {
...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)),
items
};
});
return;
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
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);
});
};
onRemoveSelectedPress = () => {
this.setState({ isConfirmRemoveModalOpen: true });
};
onRemoveSelectedConfirmed = () => {
this.props.onRemoveSelected(this.getSelectedIds());
this.setState({ isConfirmRemoveModalOpen: false });
};
onConfirmRemoveModalClose = () => {
this.setState({ isConfirmRemoveModalOpen: false });
};
onClearBlocklistPress = () => {
this.setState({ isConfirmClearModalOpen: true });
};
onClearBlocklistConfirmed = () => {
this.props.onClearBlocklistPress();
this.setState({ isConfirmClearModalOpen: false });
};
onConfirmClearModalClose = () => {
this.setState({ isConfirmClearModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
customFilters,
totalRecords,
isRemoving,
isClearingBlocklistExecuting,
onFilterSelect,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen,
isConfirmClearModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
return (
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={this.onClearBlocklistPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('BlocklistLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{
selectedFilterKey === 'all' ?
translate('NoHistoryBlocklist') :
translate('BlocklistFilterHasNoItems')
}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<BlocklistRowConnector
key={item.id}
isSelected={selectedState[item.id] || false}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
</div>
}
</PageContentBody>
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title={translate('RemoveSelected')}
message={translate('RemoveSelectedBlocklistMessageText')}
confirmLabel={translate('RemoveSelected')}
onConfirm={this.onRemoveSelectedConfirmed}
onCancel={this.onConfirmRemoveModalClose}
/>
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={this.onClearBlocklistConfirmed}
onCancel={this.onConfirmClearModalClose}
/>
</PageContent>
);
}
}
Blocklist.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,
isRemoving: PropTypes.bool.isRequired,
isClearingBlocklistExecuting: PropTypes.bool.isRequired,
onRemoveSelected: PropTypes.func.isRequired,
onClearBlocklistPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
export default Blocklist;

View File

@ -0,0 +1,329 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider } from 'App/SelectContext';
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 ConfirmModal from 'Components/Modal/ConfirmModal';
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 useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props';
import {
clearBlocklist,
fetchBlocklist,
gotoBlocklistPage,
removeBlocklistItems,
setBlocklistFilter,
setBlocklistSort,
setBlocklistTableOption,
} from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { TableOptionsChangePayload } from 'typings/Table';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import BlocklistFilterModal from './BlocklistFilterModal';
import BlocklistRow from './BlocklistRow';
function Blocklist() {
const requestCurrentPage = useCurrentPage();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isRemoving,
} = useSelector((state: AppState) => state.blocklist);
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
const isClearingBlocklistExecuting = useSelector(
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST)
);
const dispatch = useDispatch();
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false);
const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const wasClearingBlocklistExecuting = usePrevious(
isClearingBlocklistExecuting
);
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 handleRemoveSelectedPress = useCallback(() => {
setIsConfirmRemoveModalOpen(true);
}, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => {
dispatch(removeBlocklistItems({ ids: selectedIds }));
setIsConfirmRemoveModalOpen(false);
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
const handleConfirmRemoveModalClose = useCallback(() => {
setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]);
const handleClearBlocklistPress = useCallback(() => {
setIsConfirmClearModalOpen(true);
}, [setIsConfirmClearModalOpen]);
const handleClearBlocklistConfirmed = useCallback(() => {
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen, dispatch]);
const handleConfirmClearModalClose = useCallback(() => {
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoBlocklistPage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
dispatch(setBlocklistFilter({ selectedFilterKey }));
},
[dispatch]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setBlocklistSort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setBlocklistTableOption(payload));
if (payload.pageSize) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
},
[dispatch]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchBlocklist());
} else {
dispatch(gotoBlocklistPage({ page: 1 }));
}
return () => {
dispatch(clearBlocklist());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchBlocklist());
};
registerPagePopulator(repopulate);
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
useEffect(() => {
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
return (
<SelectProvider items={items}>
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null}
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
: translate('BlocklistFilterHasNoItems')}
</Alert>
) : null}
{isPopulated && !error && !!items.length ? (
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<BlocklistRow
key={item.id}
isSelected={selectedState[item.id] || false}
columns={columns}
{...item}
onSelectedChange={handleSelectedChange}
/>
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}
</PageContentBody>
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title={translate('RemoveSelected')}
message={translate('RemoveSelectedBlocklistMessageText')}
confirmLabel={translate('RemoveSelected')}
onConfirm={handleRemoveSelectedConfirmed}
onCancel={handleConfirmRemoveModalClose}
/>
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={handleClearBlocklistConfirmed}
onCancel={handleConfirmClearModalClose}
/>
</PageContent>
</SelectProvider>
);
}
export default Blocklist;

View File

@ -1,161 +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 * as blocklistActions from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Blocklist from './Blocklist';
function createMapStateToProps() {
return createSelector(
(state) => state.blocklist,
createCustomFiltersSelector('blocklist'),
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST),
(blocklist, customFilters, isClearingBlocklistExecuting) => {
return {
isClearingBlocklistExecuting,
customFilters,
...blocklist
};
}
);
}
const mapDispatchToProps = {
...blocklistActions,
executeCommand
};
class BlocklistConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchBlocklist,
gotoBlocklistFirstPage
} = this.props;
registerPagePopulator(this.repopulate);
if (useCurrentPage) {
fetchBlocklist();
} else {
gotoBlocklistFirstPage();
}
}
componentDidUpdate(prevProps) {
if (prevProps.isClearingBlocklistExecuting && !this.props.isClearingBlocklistExecuting) {
this.props.gotoBlocklistFirstPage();
}
}
componentWillUnmount() {
this.props.clearBlocklist();
unregisterPagePopulator(this.repopulate);
}
//
// Control
repopulate = () => {
this.props.fetchBlocklist();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoBlocklistFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoBlocklistPreviousPage();
};
onNextPagePress = () => {
this.props.gotoBlocklistNextPage();
};
onLastPagePress = () => {
this.props.gotoBlocklistLastPage();
};
onPageSelect = (page) => {
this.props.gotoBlocklistPage({ page });
};
onRemoveSelected = (ids) => {
this.props.removeBlocklistItems({ ids });
};
onSortPress = (sortKey) => {
this.props.setBlocklistSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setBlocklistFilter({ selectedFilterKey });
};
onClearBlocklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
};
onTableOptionChange = (payload) => {
this.props.setBlocklistTableOption(payload);
if (payload.pageSize) {
this.props.gotoBlocklistFirstPage();
}
};
//
// Render
render() {
return (
<Blocklist
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onRemoveSelected={this.onRemoveSelected}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onClearBlocklistPress={this.onClearBlocklistPress}
{...this.props}
/>
);
}
}
BlocklistConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
isClearingBlocklistExecuting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchBlocklist: PropTypes.func.isRequired,
gotoBlocklistFirstPage: PropTypes.func.isRequired,
gotoBlocklistPreviousPage: PropTypes.func.isRequired,
gotoBlocklistNextPage: PropTypes.func.isRequired,
gotoBlocklistLastPage: PropTypes.func.isRequired,
gotoBlocklistPage: PropTypes.func.isRequired,
removeBlocklistItems: PropTypes.func.isRequired,
setBlocklistSort: PropTypes.func.isRequired,
setBlocklistFilter: PropTypes.func.isRequired,
setBlocklistTableOption: PropTypes.func.isRequired,
clearBlocklist: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(BlocklistConnector)
);

View File

@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import translate from 'Utilities/String/translate';
class BlocklistDetailsModal extends Component {
//
// Render
render() {
const {
isOpen,
sourceTitle,
protocol,
indexer,
message,
onModalClose
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Details
</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem
title={translate('Name')}
data={sourceTitle}
/>
<DescriptionListItem
title={translate('Protocol')}
data={protocol}
/>
{
!!message &&
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
}
{
!!message &&
<DescriptionListItem
title={translate('Message')}
data={message}
/>
}
</DescriptionList>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
BlocklistDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
message: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};
export default BlocklistDetailsModal;

View File

@ -0,0 +1,64 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import translate from 'Utilities/String/translate';
interface BlocklistDetailsModalProps {
isOpen: boolean;
sourceTitle: string;
protocol: DownloadProtocol;
indexer?: string;
message?: string;
onModalClose: () => void;
}
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Details</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem title={translate('Name')} data={sourceTitle} />
<DescriptionListItem
title={translate('Protocol')}
data={protocol}
/>
{message ? (
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
) : null}
{message ? (
<DescriptionListItem
title={translate('Message')}
data={message}
/>
) : null}
</DescriptionList>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default BlocklistDetailsModal;

View File

@ -1,212 +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 TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import translate from 'Utilities/String/translate';
import BlocklistDetailsModal from './BlocklistDetailsModal';
import styles from './BlocklistRow.css';
class BlocklistRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
//
// Render
render() {
const {
id,
series,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
isSelected,
columns,
onSelectedChange,
onRemovePress
} = this.props;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return (
<TableRowCell key={name}>
{sourceTitle}
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell
key={name}
className={styles.languages}
>
<EpisodeLanguages
languages={languages}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell
key={name}
className={styles.quality}
>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{indexer}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
<IconButton
title={translate('RemoveFromBlocklist')}
name={icons.REMOVE}
kind={kinds.DANGER}
onPress={onRemovePress}
/>
</TableRowCell>
);
}
return null;
})
}
<BlocklistDetailsModal
isOpen={this.state.isDetailsModalOpen}
sourceTitle={sourceTitle}
protocol={protocol}
indexer={indexer}
message={message}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
BlocklistRow.propTypes = {
id: PropTypes.number.isRequired,
series: PropTypes.object.isRequired,
sourceTitle: PropTypes.string.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
date: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
message: PropTypes.string,
isSelected: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired,
onRemovePress: PropTypes.func.isRequired
};
export default BlocklistRow;

View File

@ -0,0 +1,163 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
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 EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
import Blocklist from 'typings/Blocklist';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import BlocklistDetailsModal from './BlocklistDetailsModal';
import styles from './BlocklistRow.css';
interface BlocklistRowProps extends Blocklist {
isSelected: boolean;
columns: Column[];
onSelectedChange: (options: SelectStateInputProps) => void;
}
function BlocklistRow(props: BlocklistRowProps) {
const {
id,
seriesId,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
isSelected,
columns,
onSelectedChange,
} = props;
const series = useSeries(seriesId);
const dispatch = useDispatch();
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true);
}, [setIsDetailsModalOpen]);
const handleDetailsModalClose = useCallback(() => {
setIsDetailsModalOpen(false);
}, [setIsDetailsModalOpen]);
const handleRemovePress = useCallback(() => {
dispatch(removeBlocklistItem({ id }));
}, [id, dispatch]);
if (!series) {
return null;
}
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return <TableRowCell key={name}>{sourceTitle}</TableRowCell>;
}
if (name === 'languages') {
return (
<TableRowCell key={name} className={styles.languages}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name} className={styles.quality}>
<EpisodeQuality quality={quality} />
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'date') {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
return <RelativeDateCell key={name} date={date} />;
}
if (name === 'indexer') {
return (
<TableRowCell key={name} className={styles.indexer}>
{indexer}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell key={name} className={styles.actions}>
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
<IconButton
title={translate('RemoveFromBlocklist')}
name={icons.REMOVE}
kind={kinds.DANGER}
onPress={handleRemovePress}
/>
</TableRowCell>
);
}
return null;
})}
<BlocklistDetailsModal
isOpen={isDetailsModalOpen}
sourceTitle={sourceTitle}
protocol={protocol}
indexer={indexer}
message={message}
onModalClose={handleDetailsModalClose}
/>
</TableRow>
);
}
export default BlocklistRow;

View File

@ -1,26 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import BlocklistRow from './BlocklistRow';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
(series) => {
return {
series
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onRemovePress() {
dispatch(removeBlocklistItem({ id: props.id }));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(BlocklistRow);

View File

@ -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 (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
indexer ?
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/> :
null
}
{
releaseGroup ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseGroup')}
data={releaseGroup}
/> :
null
}
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(customFormatScore)}
/> :
null
}
{
seriesMatchType ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('SeriesMatchType')}
data={seriesMatchType}
/> :
null
}
{
nzbInfoUrl ?
<span>
<DescriptionListItemTitle>
{translate('InfoUrl')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span> :
null
}
{
downloadClientNameInfo ?
<DescriptionListItem
title={translate('DownloadClient')}
data={downloadClientNameInfo}
/> :
null
}
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
age || ageHours || ageMinutes ?
<DescriptionListItem
title={translate('AgeWhenGrabbed')}
data={formatAge(age, ageHours, ageMinutes)}
/> :
null
}
{
publishedDate ?
<DescriptionListItem
title={translate('PublishedDate')}
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
/> :
null
}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const {
message
} = data;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
message ?
<DescriptionListItem
title={translate('Message')}
data={message}
/> :
null
}
</DescriptionList>
);
}
if (eventType === 'downloadFolderImported') {
const {
customFormatScore,
droppedPath,
importedPath
} = data;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
droppedPath ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Source')}
data={droppedPath}
/> :
null
}
{
importedPath ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ImportedTo')}
data={importedPath}
/> :
null
}
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(customFormatScore)}
/> :
null
}
</DescriptionList>
);
}
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 (
<DescriptionList>
<DescriptionListItem
title={translate('Name')}
data={sourceTitle}
/>
<DescriptionListItem
title={translate('Reason')}
data={reasonMessage}
/>
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(customFormatScore)}
/> :
null
}
</DescriptionList>
);
}
if (eventType === 'episodeFileRenamed') {
const {
sourcePath,
sourceRelativePath,
path,
relativePath
} = data;
return (
<DescriptionList>
<DescriptionListItem
title={translate('SourcePath')}
data={sourcePath}
/>
<DescriptionListItem
title={translate('SourceRelativePath')}
data={sourceRelativePath}
/>
<DescriptionListItem
title={translate('DestinationPath')}
data={path}
/>
<DescriptionListItem
title={translate('DestinationRelativePath')}
data={relativePath}
/>
</DescriptionList>
);
}
if (eventType === 'downloadIgnored') {
const {
message
} = data;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
message ?
<DescriptionListItem
title={translate('Message')}
data={message}
/> :
null
}
</DescriptionList>
);
}
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
</DescriptionList>
);
}
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;

View File

@ -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 (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{indexer ? (
<DescriptionListItem title={translate('Indexer')} data={indexer} />
) : null}
{releaseGroup ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseGroup')}
data={releaseGroup}
/>
) : null}
{customFormatScore && customFormatScore !== '0' ? (
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{seriesMatchType ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('SeriesMatchType')}
data={seriesMatchType}
/>
) : null}
{nzbInfoUrl ? (
<span>
<DescriptionListItemTitle>
{translate('InfoUrl')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span>
) : null}
{downloadClientNameInfo ? (
<DescriptionListItem
title={translate('DownloadClient')}
data={downloadClientNameInfo}
/>
) : null}
{downloadId ? (
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{age || ageHours || ageMinutes ? (
<DescriptionListItem
title={translate('AgeWhenGrabbed')}
data={formatAge(age, ageHours, ageMinutes)}
/>
) : null}
{publishedDate ? (
<DescriptionListItem
title={translate('PublishedDate')}
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, {
includeSeconds: true,
})}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const { message } = data as DownloadFailedHistory;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{downloadId ? (
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}
</DescriptionList>
);
}
if (eventType === 'downloadFolderImported') {
const { customFormatScore, droppedPath, importedPath } =
data as DownloadFolderImportedHistory;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{droppedPath ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Source')}
data={droppedPath}
/>
) : null}
{importedPath ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ImportedTo')}
data={importedPath}
/>
) : null}
{customFormatScore && customFormatScore !== '0' ? (
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
</DescriptionList>
);
}
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 (
<DescriptionList>
<DescriptionListItem title={translate('Name')} data={sourceTitle} />
<DescriptionListItem title={translate('Reason')} data={reasonMessage} />
{customFormatScore && customFormatScore !== '0' ? (
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'episodeFileRenamed') {
const { sourcePath, sourceRelativePath, path, relativePath } =
data as EpisodeFileRenamedHistory;
return (
<DescriptionList>
<DescriptionListItem
title={translate('SourcePath')}
data={sourcePath}
/>
<DescriptionListItem
title={translate('SourceRelativePath')}
data={sourceRelativePath}
/>
<DescriptionListItem title={translate('DestinationPath')} data={path} />
<DescriptionListItem
title={translate('DestinationRelativePath')}
data={relativePath}
/>
</DescriptionList>
);
}
if (eventType === 'downloadIgnored') {
const { message } = data as DownloadIgnoredHistory;
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
{downloadId ? (
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}
</DescriptionList>
);
}
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Name')}
data={sourceTitle}
/>
</DescriptionList>
);
}
export default HistoryDetails;

View File

@ -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);

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
@ -8,11 +7,12 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { HistoryData, HistoryEventType } from 'typings/History';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import HistoryDetails from './HistoryDetails'; import HistoryDetails from './HistoryDetails';
import styles from './HistoryDetailsModal.css'; import styles from './HistoryDetailsModal.css';
function getHeaderTitle(eventType) { function getHeaderTitle(eventType: HistoryEventType) {
switch (eventType) { switch (eventType) {
case 'grabbed': case 'grabbed':
return translate('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 { const {
isOpen, isOpen,
eventType, eventType,
@ -42,18 +55,13 @@ function HistoryDetailsModal(props) {
shortDateFormat, shortDateFormat,
timeFormat, timeFormat,
onMarkAsFailedPress, onMarkAsFailedPress,
onModalClose onModalClose,
} = props; } = props;
return ( return (
<Modal <Modal isOpen={isOpen} onModalClose={onModalClose}>
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>{getHeaderTitle(eventType)}</ModalHeader>
{getHeaderTitle(eventType)}
</ModalHeader>
<ModalBody> <ModalBody>
<HistoryDetails <HistoryDetails
@ -67,8 +75,7 @@ function HistoryDetailsModal(props) {
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
{ {eventType === 'grabbed' && (
eventType === 'grabbed' &&
<SpinnerButton <SpinnerButton
className={styles.markAsFailedButton} className={styles.markAsFailedButton}
kind={kinds.DANGER} kind={kinds.DANGER}
@ -77,34 +84,17 @@ function HistoryDetailsModal(props) {
> >
{translate('MarkAsFailed')} {translate('MarkAsFailed')}
</SpinnerButton> </SpinnerButton>
} )}
<Button <Button onPress={onModalClose}>{translate('Close')}</Button>
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
} }
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 = { HistoryDetailsModal.defaultProps = {
isMarkingAsFailed: false isMarkingAsFailed: false,
}; };
export default HistoryDetailsModal; export default HistoryDetailsModal;

View File

@ -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 (
<PageContent title={translate('History')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('Refresh')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={onFirstPagePress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={HistoryFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetchingAny && !isAllPopulated &&
<LoadingIndicator />
}
{
!isFetchingAny && hasError &&
<Alert kind={kinds.DANGER}>
{translate('HistoryLoadError')}
</Alert>
}
{
// 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 &&
<Alert kind={kinds.INFO}>
{translate('NoHistoryFound')}
</Alert>
}
{
isAllPopulated && !hasError && !!items.length &&
<div>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
items.map((item) => {
return (
<HistoryRowConnector
key={item.id}
columns={columns}
{...item}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetchingAny}
onFirstPagePress={onFirstPagePress}
{...otherProps}
/>
</div>
}
</PageContentBody>
</PageContent>
);
}
}
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;

View File

@ -0,0 +1,231 @@
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,
pageSize,
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<HistoryItem, number>(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 (
<PageContent title={translate('History')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('Refresh')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={handleFirstPagePress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={HistoryFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{isFetchingAny && !isAllPopulated ? <LoadingIndicator /> : null}
{!isFetchingAny && hasError ? (
<Alert kind={kinds.DANGER}>{translate('HistoryLoadError')}</Alert>
) : 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 ? (
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
) : null
}
{isAllPopulated && !hasError && items.length ? (
<div>
<Table
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<HistoryRow key={item.id} columns={columns} {...item} />
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}
</PageContentBody>
</PageContent>
);
}
export default History;

View File

@ -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 (
<History
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
{...this.props}
/>
);
}
}
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)
);

View File

@ -1,12 +1,17 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import {
EpisodeFileDeletedHistory,
GrabbedHistoryData,
HistoryData,
HistoryEventType,
} from 'typings/History';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './HistoryEventTypeCell.css'; import styles from './HistoryEventTypeCell.css';
function getIconName(eventType, data) { function getIconName(eventType: HistoryEventType, data: HistoryData) {
switch (eventType) { switch (eventType) {
case 'grabbed': case 'grabbed':
return icons.DOWNLOADING; return icons.DOWNLOADING;
@ -17,7 +22,9 @@ function getIconName(eventType, data) {
case 'downloadFailed': case 'downloadFailed':
return icons.DOWNLOADING; return icons.DOWNLOADING;
case 'episodeFileDeleted': case 'episodeFileDeleted':
return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE; return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk'
? icons.FILE_MISSING
: icons.DELETE;
case 'episodeFileRenamed': case 'episodeFileRenamed':
return icons.ORGANIZE; return icons.ORGANIZE;
case 'downloadIgnored': case 'downloadIgnored':
@ -27,7 +34,7 @@ function getIconName(eventType, data) {
} }
} }
function getIconKind(eventType) { function getIconKind(eventType: HistoryEventType) {
switch (eventType) { switch (eventType) {
case 'downloadFailed': case 'downloadFailed':
return kinds.DANGER; return kinds.DANGER;
@ -36,10 +43,13 @@ function getIconKind(eventType) {
} }
} }
function getTooltip(eventType, data) { function getTooltip(eventType: HistoryEventType, data: HistoryData) {
switch (eventType) { switch (eventType) {
case 'grabbed': 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': case 'seriesFolderImported':
return translate('SeriesFolderImportedTooltip'); return translate('SeriesFolderImportedTooltip');
case 'downloadFolderImported': case 'downloadFolderImported':
@ -47,7 +57,9 @@ function getTooltip(eventType, data) {
case 'downloadFailed': case 'downloadFailed':
return translate('DownloadFailedEpisodeTooltip'); return translate('DownloadFailedEpisodeTooltip');
case 'episodeFileDeleted': case 'episodeFileDeleted':
return data.reason === 'MissingFromDisk' ? translate('EpisodeFileMissingTooltip') : translate('EpisodeFileDeletedTooltip'); return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk'
? translate('EpisodeFileMissingTooltip')
: translate('EpisodeFileDeletedTooltip');
case 'episodeFileRenamed': case 'episodeFileRenamed':
return translate('EpisodeFileRenamedTooltip'); return translate('EpisodeFileRenamedTooltip');
case 'downloadIgnored': 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 iconName = getIconName(eventType, data);
const iconKind = getIconKind(eventType); const iconKind = getIconKind(eventType);
const tooltip = getTooltip(eventType, data); const tooltip = getTooltip(eventType, data);
return ( return (
<TableRowCell <TableRowCell className={styles.cell} title={tooltip}>
className={styles.cell} <Icon name={iconName} kind={iconKind} />
title={tooltip}
>
<Icon
name={iconName}
kind={iconKind}
/>
</TableRowCell> </TableRowCell>
); );
} }
HistoryEventTypeCell.propTypes = {
eventType: PropTypes.string.isRequired,
data: PropTypes.object
};
HistoryEventTypeCell.defaultProps = {
data: {}
};
export default HistoryEventTypeCell; export default HistoryEventTypeCell;

View File

@ -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 (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'eventType') {
return (
<HistoryEventTypeCell
key={name}
eventType={eventType}
data={data}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series.seriesType}
alternateTitles={series.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
/>
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
episodeId={episodeId}
episodeEntity={episodeEntities.EPISODES}
seriesId={series.id}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/>
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
isCutoffMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell
key={name}
className={styles.downloadClient}
>
{data.downloadClient}
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{data.indexer}
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'releaseGroup') {
return (
<TableRowCell
key={name}
className={styles.releaseGroup}
>
{data.releaseGroup}
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return (
<TableRowCell
key={name}
>
{sourceTitle}
</TableRowCell>
);
}
if (name === 'details') {
return (
<TableRowCell
key={name}
className={styles.details}
>
<div className={styles.actionContents}>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
</div>
</TableRowCell>
);
}
return null;
})
}
<HistoryDetailsModal
isOpen={this.state.isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={onMarkAsFailedPress}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
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;

View File

@ -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 (
<TableRow>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'eventType') {
return (
<HistoryEventTypeCell
key={name}
eventType={eventType}
data={data}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series.seriesType}
alternateTitles={series.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
/>
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
episodeId={episodeId}
episodeEntity={episodeEntities.EPISODES}
seriesId={series.id}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/>
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'date') {
return <RelativeDateCell key={name} date={date} />;
}
if (name === 'downloadClient') {
return (
<TableRowCell key={name} className={styles.downloadClient}>
{'downloadClient' in data ? data.downloadClient : ''}
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell key={name} className={styles.indexer}>
{'indexer' in data ? data.indexer : ''}
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell key={name} className={styles.customFormatScore}>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'releaseGroup') {
return (
<TableRowCell key={name} className={styles.releaseGroup}>
{'releaseGroup' in data ? data.releaseGroup : ''}
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return <TableRowCell key={name}>{sourceTitle}</TableRowCell>;
}
if (name === 'details') {
return (
<TableRowCell key={name} className={styles.details}>
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
</TableRowCell>
);
}
return null;
})}
<HistoryDetailsModal
isOpen={isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose}
/>
</TableRow>
);
}
HistoryRow.defaultProps = {
customFormats: [],
};
export default HistoryRow;

View File

@ -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 (
<HistoryRow
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
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);

View File

@ -11,3 +11,7 @@
border-color: var(--usenetColor); border-color: var(--usenetColor);
background-color: var(--usenetColor); background-color: var(--usenetColor);
} }
.unknown {
composes: label from '~Components/Label.css';
}

View File

@ -2,6 +2,7 @@
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'torrent': string; 'torrent': string;
'unknown': string;
'usenet': string; 'usenet': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;

View File

@ -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 (
<Label className={styles[protocol]}>
{protocolName}
</Label>
);
}
ProtocolLabel.propTypes = {
protocol: PropTypes.string.isRequired
};
export default ProtocolLabel;

View File

@ -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 <Label className={styles[protocol]}>{protocolName}</Label>;
}
export default ProtocolLabel;

View File

@ -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 (
<PageContent title={translate('Queue')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('GrabSelected')}
iconName={icons.DOWNLOAD}
isDisabled={disableSelectedActions || !isPendingSelected}
isSpinning={isGrabbing}
onPress={this.onGrabSelectedPress}
/>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={disableSelectedActions}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
>
<TableOptionsModalWrapper
columns={columns}
maxPageSize={200}
{...otherProps}
optionsComponent={QueueOptionsConnector}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={QueueFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isRefreshing && !isAllPopulated ?
<LoadingIndicator /> :
null
}
{
!isRefreshing && hasError ?
<Alert kind={kinds.DANGER}>
{translate('QueueLoadError')}
</Alert> :
null
}
{
isAllPopulated && !hasError && !items.length ?
<Alert kind={kinds.INFO}>
{
selectedFilterKey !== 'all' && count > 0 ?
translate('QueueFilterHasNoItems') :
translate('QueueIsEmpty')
}
</Alert> :
null
}
{
isAllPopulated && !hasError && !!items.length ?
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
optionsComponent={QueueOptionsConnector}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<QueueRowConnector
key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
onQueueRowModalOpenOrClose={this.onQueueRowModalOpenOrClose}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isRefreshing}
{...otherProps}
/>
</div> :
null
}
</PageContentBody>
<RemoveQueueItemModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
canChangeCategory={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
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}
/>
</PageContent>
);
}
}
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;

View File

@ -0,0 +1,415 @@
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,
pageSize,
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<ReactElement | null>(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<QueueItem, number | undefined>(
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 = (
<PageContentBody>
{isRefreshing && !isAllPopulated ? <LoadingIndicator /> : null}
{!isRefreshing && hasError ? (
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
) : null}
{isAllPopulated && !hasError && !items.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey !== 'all' && count > 0
? translate('QueueFilterHasNoItems')
: translate('QueueIsEmpty')}
</Alert>
) : null}
{isAllPopulated && !hasError && !!items.length ? (
<div>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<QueueRow
key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={handleSelectedChange}
onQueueRowModalOpenOrClose={
handleQueueRowModalOpenOrClose
}
/>
);
})}
</TableBody>
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}
</PageContentBody>
);
}
return (
<PageContent title={translate('Queue')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isRefreshing}
onPress={handleRefreshPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('GrabSelected')}
iconName={icons.DOWNLOAD}
isDisabled={disableSelectedActions || !isPendingSelected}
isSpinning={isGrabbing}
onPress={handleGrabSelectedPress}
/>
<PageToolbarButton
label={translate('RemoveSelected')}
iconName={icons.REMOVE}
isDisabled={disableSelectedActions}
isSpinning={isRemoving}
onPress={handleRemoveSelectedPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
maxPageSize={200}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={QueueFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
{currentQueue.current}
<RemoveQueueItemModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
canChangeCategory={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
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}
/>
</PageContent>
);
}
export default Queue;

View File

@ -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 (
<Queue
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onRefreshPress={this.onRefreshPress}
onGrabSelectedPress={this.onGrabSelectedPress}
onRemoveSelectedPress={this.onRemoveSelectedPress}
{...this.props}
/>
);
}
}
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)
);

View File

@ -1,36 +1,49 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import { icons, tooltipPositions } from 'Helpers/Props'; import { icons, tooltipPositions } from 'Helpers/Props';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
} from 'typings/Queue';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import QueueStatus from './QueueStatus'; import QueueStatus from './QueueStatus';
import styles from './QueueDetails.css'; 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 { const {
title, title,
size, size,
sizeleft, sizeleft,
status, status,
trackedDownloadState, trackedDownloadState = 'downloading',
trackedDownloadStatus, trackedDownloadStatus = 'ok',
statusMessages, statusMessages,
errorMessage, errorMessage,
progressBar progressBar,
} = props; } = props;
const progress = (100 - sizeleft / size * 100); const progress = 100 - (sizeleft / size) * 100;
const isDownloading = status === 'downloading'; const isDownloading = status === 'downloading';
const isPaused = status === 'paused'; const isPaused = status === 'paused';
const hasWarning = trackedDownloadStatus === 'warning'; const hasWarning = trackedDownloadStatus === 'warning';
const hasError = trackedDownloadStatus === 'error'; const hasError = trackedDownloadStatus === 'error';
if ( if ((isDownloading || isPaused) && !hasWarning && !hasError) {
(isDownloading || isPaused) &&
!hasWarning &&
!hasError
) {
const state = isPaused ? translate('Paused') : translate('Downloading'); const state = isPaused ? translate('Paused') : translate('Downloading');
if (progress < 5) { if (progress < 5) {
@ -45,11 +58,9 @@ function QueueDetails(props) {
return ( return (
<Popover <Popover
className={styles.progressBarContainer} className={styles.progressBarContainer}
anchor={progressBar} anchor={progressBar!}
title={`${state} - ${progress.toFixed(1)}%`} title={`${state} - ${progress.toFixed(1)}%`}
body={ body={<div>{title}</div>}
<div>{title}</div>
}
position={tooltipPositions.LEFT} 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; export default QueueDetails;

View File

@ -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 (
<Fragment>
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={this.onOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
QueueOptions.propTypes = {
includeUnknownSeriesItems: PropTypes.bool.isRequired,
onOptionChange: PropTypes.func.isRequired
};
export default QueueOptions;

View File

@ -0,0 +1,48 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
function QueueOptions() {
const dispatch = useDispatch();
const { includeUnknownSeriesItems } = useSelector(
(state: AppState) => state.queue.options
);
const handleOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {
dispatch(
setQueueOption({
[name]: value,
})
);
if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 }));
}
},
[dispatch]
);
return (
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={handleOptionChange}
/>
</FormGroup>
);
}
export default QueueOptions;

View File

@ -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);

View File

@ -26,4 +26,5 @@
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 70px; width: 70px;
text-align: right;
} }

View File

@ -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 RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
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 (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<QueueStatusCell
key={name}
sourceTitle={title}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
{
series ?
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/> :
title
}
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
{
episode ?
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series.seriesType}
alternateTitles={series.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/> :
'-'
}
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
{
episode ?
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeFileId={episode.episodeFileId}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/> :
'-'
}
</TableRowCell>
);
}
if (name === 'episodes.airDateUtc') {
if (episode) {
return (
<RelativeDateCellConnector
key={name}
date={episode.airDateUtc}
/>
);
}
return (
<TableRowCell key={name}>
-
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages
languages={languages}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
{
quality ?
<EpisodeQuality
quality={quality}
/> :
null
}
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
<ProtocolLabel
protocol={protocol}
/>
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell key={name}>
{indexer}
</TableRowCell>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell key={name}>
{downloadClient}
</TableRowCell>
);
}
if (name === 'title') {
return (
<TableRowCell key={name}>
{title}
</TableRowCell>
);
}
if (name === 'size') {
return (
<TableRowCell key={name}>{formatBytes(size)}</TableRowCell>
);
}
if (name === 'outputPath') {
return (
<TableRowCell key={name}>
{outputPath}
</TableRowCell>
);
}
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
size={size}
sizeleft={sizeleft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
);
}
if (name === 'progress') {
return (
<TableRowCell
key={name}
className={styles.progress}
>
{
!!progress &&
<ProgressBar
progress={progress}
title={`${progress.toFixed(1)}%`}
/>
}
</TableRowCell>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
date={added}
/>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
{
showInteractiveImport &&
<IconButton
name={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
}
{
isPending &&
<SpinnerIconButton
name={icons.DOWNLOAD}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
isSpinning={isGrabbing}
onPress={onGrabPress}
/>
}
<SpinnerIconButton
title={translate('RemoveFromQueue')}
name={icons.REMOVE}
isSpinning={isRemoving}
onPress={this.onRemoveQueueItemPress}
/>
</TableRowCell>
);
}
return null;
})
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
downloadId={downloadId}
title={title}
onModalClose={this.onInteractiveImportModalClose}
/>
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
canChangeCategory={!!downloadClientHasPostImportCategory}
canIgnore={!!series}
isPending={isPending}
onRemovePress={this.onRemoveQueueItemModalConfirmed}
onModalClose={this.onRemoveQueueItemModalClose}
/>
</TableRow>
);
}
}
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;

View File

@ -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 (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<QueueStatusCell
key={name}
sourceTitle={title}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
{series ? (
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
) : (
title
)}
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
{episode ? (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={
episode.sceneAbsoluteEpisodeNumber
}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
) : (
'-'
)}
</TableRowCell>
);
}
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
{series && episode ? (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
) : (
'-'
)}
</TableRowCell>
);
}
if (name === 'episodes.airDateUtc') {
if (episode) {
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
}
return <TableRowCell key={name}>-</TableRowCell>;
}
if (name === 'languages') {
return (
<TableRowCell key={name}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
{quality ? <EpisodeQuality quality={quality} /> : null}
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell key={name} className={styles.customFormatScore}>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
<ProtocolLabel protocol={protocol} />
</TableRowCell>
);
}
if (name === 'indexer') {
return <TableRowCell key={name}>{indexer}</TableRowCell>;
}
if (name === 'downloadClient') {
return <TableRowCell key={name}>{downloadClient}</TableRowCell>;
}
if (name === 'title') {
return <TableRowCell key={name}>{title}</TableRowCell>;
}
if (name === 'size') {
return <TableRowCell key={name}>{formatBytes(size)}</TableRowCell>;
}
if (name === 'outputPath') {
return <TableRowCell key={name}>{outputPath}</TableRowCell>;
}
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
size={size}
sizeleft={sizeleft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
);
}
if (name === 'progress') {
return (
<TableRowCell key={name} className={styles.progress}>
{!!progress && (
<ProgressBar
progress={progress}
title={`${progress.toFixed(1)}%`}
/>
)}
</TableRowCell>
);
}
if (name === 'added') {
return <RelativeDateCell key={name} date={added} />;
}
if (name === 'actions') {
return (
<TableRowCell key={name} className={styles.actions}>
{showInteractiveImport ? (
<IconButton
name={icons.INTERACTIVE}
onPress={handleInteractiveImportPress}
/>
) : null}
{isPending ? (
<SpinnerIconButton
name={icons.DOWNLOAD}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
isSpinning={isGrabbing}
onPress={handleGrabPress}
/>
) : null}
<SpinnerIconButton
title={translate('RemoveFromQueue')}
name={icons.REMOVE}
isSpinning={isRemoving}
onPress={handleRemoveQueueItemPress}
/>
</TableRowCell>
);
}
return null;
})}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
downloadId={downloadId}
modalTitle={title}
onModalClose={handleInteractiveImportModalClose}
/>
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
canChangeCategory={!!downloadClientHasPostImportCategory}
canIgnore={!!series}
isPending={isPending}
onRemovePress={handleRemoveQueueItemModalConfirmed}
onModalClose={handleRemoveQueueItemModalClose}
/>
</TableRow>
);
}
export default QueueRow;

View File

@ -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 (
<QueueRow
{...this.props}
onGrabPress={this.onGrabPress}
onRemoveQueueItemPress={this.onRemoveQueueItemPress}
/>
);
}
}
QueueRowConnector.propTypes = {
id: PropTypes.number.isRequired,
episode: PropTypes.object,
grabQueueItem: PropTypes.func.isRequired,
removeQueueItem: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector);

View File

@ -1,51 +1,59 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover'; 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 translate from 'Utilities/String/translate';
import styles from './QueueStatus.css'; import styles from './QueueStatus.css';
function getDetailedPopoverBody(statusMessages) { function getDetailedPopoverBody(statusMessages: StatusMessage[]) {
return ( return (
<div> <div>
{ {statusMessages.map(({ title, messages }) => {
statusMessages.map(({ title, messages }) => {
return ( return (
<div <div
key={title} key={title}
className={messages.length ? undefined: styles.noMessages} className={messages.length ? undefined : styles.noMessages}
> >
{title} {title}
<ul> <ul>
{ {messages.map((message) => {
messages.map((message) => { return <li key={message}>{message}</li>;
return ( })}
<li key={message}>
{message}
</li>
);
})
}
</ul> </ul>
</div> </div>
); );
}) })}
}
</div> </div>
); );
} }
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 { const {
sourceTitle, sourceTitle,
status, status,
trackedDownloadStatus, trackedDownloadStatus = 'ok',
trackedDownloadState, trackedDownloadState = 'downloading',
statusMessages, statusMessages = [],
errorMessage, errorMessage,
position, position,
canFlip canFlip = false,
} = props; } = props;
const hasWarning = trackedDownloadStatus === 'warning'; const hasWarning = trackedDownloadStatus === 'warning';
@ -115,7 +123,8 @@ function QueueStatus(props) {
if (status === 'warning') { if (status === 'warning') {
iconName = icons.DOWNLOADING; iconName = icons.DOWNLOADING;
iconKind = kinds.WARNING; iconKind = kinds.WARNING;
const warningMessage = errorMessage || translate('CheckDownloadClientForDetails'); const warningMessage =
errorMessage || translate('CheckDownloadClientForDetails');
title = translate('DownloadWarning', { warningMessage }); title = translate('DownloadWarning', { warningMessage });
} }
@ -133,35 +142,23 @@ function QueueStatus(props) {
return ( return (
<Popover <Popover
anchor={ anchor={<Icon name={iconName} kind={iconKind} />}
<Icon
name={iconName}
kind={iconKind}
/>
}
title={title} title={title}
body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} body={
hasWarning || hasError
? getDetailedPopoverBody(statusMessages)
: sourceTitle
}
position={position} position={position}
canFlip={canFlip} 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 = { QueueStatus.defaultProps = {
trackedDownloadStatus: 'ok', trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading', trackedDownloadState: 'downloading',
canFlip: false canFlip: false,
}; };
export default QueueStatus; export default QueueStatus;

View File

@ -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 (
<TableRowCell className={styles.status}>
<QueueStatus
sourceTitle={sourceTitle}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
position={tooltipPositions.RIGHT}
/>
</TableRowCell>
);
}
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;

View File

@ -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 (
<TableRowCell className={styles.status}>
<QueueStatus
sourceTitle={sourceTitle}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
trackedDownloadState={trackedDownloadState}
statusMessages={statusMessages}
errorMessage={errorMessage}
position="right"
/>
</TableRowCell>
);
}
export default QueueStatusCell;

View File

@ -12,7 +12,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemModal.css'; import styles from './RemoveQueueItemModal.css';
interface RemovePressProps { export interface RemovePressProps {
remove: boolean; remove: boolean;
changeCategory: boolean; changeCategory: boolean;
blocklist: boolean; blocklist: boolean;
@ -21,7 +21,7 @@ interface RemovePressProps {
interface RemoveQueueItemModalProps { interface RemoveQueueItemModalProps {
isOpen: boolean; isOpen: boolean;
sourceTitle: string; sourceTitle?: string;
canChangeCategory: boolean; canChangeCategory: boolean;
canIgnore: boolean; canIgnore: boolean;
isPending: boolean; isPending: boolean;
@ -39,7 +39,7 @@ type BlocklistMethod =
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) { function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
const { const {
isOpen, isOpen,
sourceTitle, sourceTitle = '',
canIgnore, canIgnore,
canChangeCategory, canChangeCategory,
isPending, isPending,

View File

@ -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 (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
);
}
export default QueueStatus;

View File

@ -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 (
<PageSidebarStatus
{...this.props}
/>
);
}
}
QueueStatusConnector.propTypes = {
isConnected: PropTypes.bool.isRequired,
isReconnecting: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
fetchQueueStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector);

View File

@ -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;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
@ -11,7 +10,18 @@ import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './TimeleftCell.css'; 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 { const {
estimatedCompletionTime, estimatedCompletionTime,
timeleft, timeleft,
@ -20,16 +30,18 @@ function TimeleftCell(props) {
sizeleft, sizeleft,
showRelativeDates, showRelativeDates,
shortDateFormat, shortDateFormat,
timeFormat timeFormat,
} = props; } = props;
if (status === 'delay') { if (status === 'delay') {
const date = getRelativeDate({ const date = getRelativeDate({
date: estimatedCompletionTime, date: estimatedCompletionTime,
shortDateFormat, shortDateFormat,
showRelativeDates showRelativeDates,
});
const time = formatTime(estimatedCompletionTime, timeFormat, {
includeMinuteZero: true,
}); });
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return ( return (
<TableRowCell className={styles.timeleft}> <TableRowCell className={styles.timeleft}>
@ -47,9 +59,11 @@ function TimeleftCell(props) {
const date = getRelativeDate({ const date = getRelativeDate({
date: estimatedCompletionTime, date: estimatedCompletionTime,
shortDateFormat, shortDateFormat,
showRelativeDates showRelativeDates,
});
const time = formatTime(estimatedCompletionTime, timeFormat, {
includeMinuteZero: true,
}); });
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return ( return (
<TableRowCell className={styles.timeleft}> <TableRowCell className={styles.timeleft}>
@ -64,11 +78,7 @@ function TimeleftCell(props) {
} }
if (!timeleft || status === 'completed' || status === 'failed') { if (!timeleft || status === 'completed' || status === 'failed') {
return ( return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
<TableRowCell className={styles.timeleft}>
-
</TableRowCell>
);
} }
const totalSize = formatBytes(size); 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; export default TimeleftCell;

View File

@ -1,13 +1,19 @@
import { ConnectedRouter } from 'connected-react-router'; import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import DocumentTitle from 'react-document-title'; import DocumentTitle from 'react-document-title';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Store } from 'redux';
import PageConnector from 'Components/Page/PageConnector'; import PageConnector from 'Components/Page/PageConnector';
import ApplyTheme from './ApplyTheme'; import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
function App({ store, history }) { interface AppProps {
store: Store;
history: ConnectedRouterProps['history'];
}
function App({ store, history }: AppProps) {
return ( return (
<DocumentTitle title={window.Sonarr.instanceName}> <DocumentTitle title={window.Sonarr.instanceName}>
<Provider store={store}> <Provider store={store}>
@ -24,7 +30,7 @@ function App({ store, history }) {
App.propTypes = { App.propTypes = {
store: PropTypes.object.isRequired, store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired history: PropTypes.object.isRequired,
}; };
export default App; export default App;

View File

@ -1,280 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import BlocklistConnector from 'Activity/Blocklist/BlocklistConnector';
import HistoryConnector from 'Activity/History/HistoryConnector';
import QueueConnector from 'Activity/Queue/QueueConnector';
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesIndex from 'Series/Index/SeriesIndex';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import Profiles from 'Settings/Profiles/Profiles';
import QualityConnector from 'Settings/Quality/QualityConnector';
import Settings from 'Settings/Settings';
import TagSettings from 'Settings/Tags/TagSettings';
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import BackupsConnector from 'System/Backup/BackupsConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
function AppRoutes(props) {
const {
app
} = props;
return (
<Switch>
{/*
Series
*/}
<Route
exact={true}
path="/"
component={SeriesIndex}
/>
{
window.Sonarr.urlBase &&
<Route
exact={true}
path="/"
addUrlBase={false}
render={() => {
return (
<Redirect
to={getPathWithUrlBase('/')}
component={app}
/>
);
}}
/>
}
<Route
path="/add/new"
component={AddNewSeriesConnector}
/>
<Route
path="/add/import"
component={ImportSeries}
/>
<Route
path="/serieseditor"
exact={true}
render={() => {
return (
<Redirect
to={getPathWithUrlBase('/')}
component={app}
/>
);
}}
/>
<Route
path="/seasonpass"
exact={true}
render={() => {
return (
<Redirect
to={getPathWithUrlBase('/')}
component={app}
/>
);
}}
/>
<Route
path="/series/:titleSlug"
component={SeriesDetailsPageConnector}
/>
{/*
Calendar
*/}
<Route
path="/calendar"
component={CalendarPageConnector}
/>
{/*
Activity
*/}
<Route
path="/activity/history"
component={HistoryConnector}
/>
<Route
path="/activity/queue"
component={QueueConnector}
/>
<Route
path="/activity/blocklist"
component={BlocklistConnector}
/>
{/*
Wanted
*/}
<Route
path="/wanted/missing"
component={MissingConnector}
/>
<Route
path="/wanted/cutoffunmet"
component={CutoffUnmetConnector}
/>
{/*
Settings
*/}
<Route
exact={true}
path="/settings"
component={Settings}
/>
<Route
path="/settings/mediamanagement"
component={MediaManagementConnector}
/>
<Route
path="/settings/profiles"
component={Profiles}
/>
<Route
path="/settings/quality"
component={QualityConnector}
/>
<Route
path="/settings/customformats"
component={CustomFormatSettingsPage}
/>
<Route
path="/settings/indexers"
component={IndexerSettingsConnector}
/>
<Route
path="/settings/downloadclients"
component={DownloadClientSettingsConnector}
/>
<Route
path="/settings/importlists"
component={ImportListSettingsConnector}
/>
<Route
path="/settings/connect"
component={NotificationSettings}
/>
<Route
path="/settings/metadata"
component={MetadataSettings}
/>
<Route
path="/settings/metadatasource"
component={MetadataSourceSettings}
/>
<Route
path="/settings/tags"
component={TagSettings}
/>
<Route
path="/settings/general"
component={GeneralSettingsConnector}
/>
<Route
path="/settings/ui"
component={UISettingsConnector}
/>
{/*
System
*/}
<Route
path="/system/status"
component={Status}
/>
<Route
path="/system/tasks"
component={Tasks}
/>
<Route
path="/system/backup"
component={BackupsConnector}
/>
<Route
path="/system/updates"
component={Updates}
/>
<Route
path="/system/events"
component={LogsTableConnector}
/>
<Route
path="/system/logs/files"
component={Logs}
/>
{/*
Not Found
*/}
<Route
path="*"
component={NotFound}
/>
</Switch>
);
}
AppRoutes.propTypes = {
app: PropTypes.func.isRequired
};
export default AppRoutes;

View File

@ -0,0 +1,172 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import Blocklist from 'Activity/Blocklist/Blocklist';
import History from 'Activity/History/History';
import Queue from 'Activity/Queue/Queue';
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesIndex from 'Series/Index/SeriesIndex';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import Profiles from 'Settings/Profiles/Profiles';
import QualityConnector from 'Settings/Quality/QualityConnector';
import Settings from 'Settings/Settings';
import TagSettings from 'Settings/Tags/TagSettings';
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import BackupsConnector from 'System/Backup/BackupsConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
function RedirectWithUrlBase() {
return <Redirect to={getPathWithUrlBase('/')} />;
}
function AppRoutes() {
return (
<Switch>
{/*
Series
*/}
<Route exact={true} path="/" component={SeriesIndex} />
{window.Sonarr.urlBase && (
<Route
exact={true}
path="/"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
addUrlBase={false}
render={RedirectWithUrlBase}
/>
)}
<Route path="/add/new" component={AddNewSeriesConnector} />
<Route path="/add/import" component={ImportSeries} />
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />
{/*
Calendar
*/}
<Route path="/calendar" component={CalendarPageConnector} />
{/*
Activity
*/}
<Route path="/activity/history" component={History} />
<Route path="/activity/queue" component={Queue} />
<Route path="/activity/blocklist" component={Blocklist} />
{/*
Wanted
*/}
<Route path="/wanted/missing" component={MissingConnector} />
<Route path="/wanted/cutoffunmet" component={CutoffUnmetConnector} />
{/*
Settings
*/}
<Route exact={true} path="/settings" component={Settings} />
<Route
path="/settings/mediamanagement"
component={MediaManagementConnector}
/>
<Route path="/settings/profiles" component={Profiles} />
<Route path="/settings/quality" component={QualityConnector} />
<Route
path="/settings/customformats"
component={CustomFormatSettingsPage}
/>
<Route path="/settings/indexers" component={IndexerSettingsConnector} />
<Route
path="/settings/downloadclients"
component={DownloadClientSettingsConnector}
/>
<Route
path="/settings/importlists"
component={ImportListSettingsConnector}
/>
<Route path="/settings/connect" component={NotificationSettings} />
<Route path="/settings/metadata" component={MetadataSettings} />
<Route
path="/settings/metadatasource"
component={MetadataSourceSettings}
/>
<Route path="/settings/tags" component={TagSettings} />
<Route path="/settings/general" component={GeneralSettingsConnector} />
<Route path="/settings/ui" component={UISettingsConnector} />
{/*
System
*/}
<Route path="/system/status" component={Status} />
<Route path="/system/tasks" component={Tasks} />
<Route path="/system/backup" component={BackupsConnector} />
<Route path="/system/updates" component={Updates} />
<Route path="/system/events" component={LogsTableConnector} />
<Route path="/system/logs/files" component={Logs} />
{/*
Not Found
*/}
<Route path="*" component={NotFound} />
</Switch>
);
}
AppRoutes.propTypes = {
app: PropTypes.func.isRequired,
};
export default AppRoutes;

View File

@ -1,30 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
function AppUpdatedModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AppUpdatedModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
AppUpdatedModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AppUpdatedModal;

View File

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import Modal from 'Components/Modal/Modal';
import AppUpdatedModalContent from './AppUpdatedModalContent';
interface AppUpdatedModalProps {
isOpen: boolean;
onModalClose: (...args: unknown[]) => unknown;
}
function AppUpdatedModal(props: AppUpdatedModalProps) {
const { isOpen, onModalClose } = props;
const handleModalClose = useCallback(() => {
location.reload();
}, []);
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AppUpdatedModalContent onModalClose={handleModalClose} />
</Modal>
);
}
export default AppUpdatedModal;

View File

@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import AppUpdatedModal from './AppUpdatedModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
location.reload();
}
};
}
export default connect(null, createMapDispatchToProps)(AppUpdatedModal);

View File

@ -1,139 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import UpdateChanges from 'System/Updates/UpdateChanges';
import translate from 'Utilities/String/translate';
import styles from './AppUpdatedModalContent.css';
function mergeUpdates(items, version, prevVersion) {
let installedIndex = items.findIndex((u) => u.version === version);
let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion);
if (installedIndex === -1) {
installedIndex = 0;
}
if (installedPreviouslyIndex === -1) {
installedPreviouslyIndex = items.length;
} else if (installedPreviouslyIndex === installedIndex && items.length) {
installedPreviouslyIndex += 1;
}
const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
if (!appliedUpdates.length) {
return null;
}
const appliedChanges = { new: [], fixed: [] };
appliedUpdates.forEach((u) => {
if (u.changes) {
appliedChanges.new.push(... u.changes.new);
appliedChanges.fixed.push(... u.changes.fixed);
}
});
const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges });
if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
mergedUpdate.changes = null;
}
return mergedUpdate;
}
function AppUpdatedModalContent(props) {
const {
version,
prevVersion,
isPopulated,
error,
items,
onSeeChangesPress,
onModalClose
} = props;
const update = mergeUpdates(items, version, prevVersion);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AppUpdated')}
</ModalHeader>
<ModalBody>
<div>
<InlineMarkdown data={translate('AppUpdatedVersion', { version })} blockClassName={styles.version} />
</div>
{
isPopulated && !error && !!update &&
<div>
{
!update.changes &&
<div className={styles.maintenance}>{translate('MaintenanceRelease')}</div>
}
{
!!update.changes &&
<div>
<div className={styles.changes}>
{translate('WhatsNew')}
</div>
<UpdateChanges
title={translate('New')}
changes={update.changes.new}
/>
<UpdateChanges
title={translate('Fixed')}
changes={update.changes.fixed}
/>
</div>
}
</div>
}
{
!isPopulated && !error &&
<LoadingIndicator />
}
</ModalBody>
<ModalFooter>
<Button
onPress={onSeeChangesPress}
>
{translate('RecentChanges')}
</Button>
<Button
kind={kinds.PRIMARY}
onPress={onModalClose}
>
{translate('Reload')}
</Button>
</ModalFooter>
</ModalContent>
);
}
AppUpdatedModalContent.propTypes = {
version: PropTypes.string.isRequired,
prevVersion: PropTypes.string,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onSeeChangesPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AppUpdatedModalContent;

View File

@ -0,0 +1,145 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges';
import Update from 'typings/Update';
import translate from 'Utilities/String/translate';
import AppState from './State/AppState';
import styles from './AppUpdatedModalContent.css';
function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
let installedIndex = items.findIndex((u) => u.version === version);
let installedPreviouslyIndex = items.findIndex(
(u) => u.version === prevVersion
);
if (installedIndex === -1) {
installedIndex = 0;
}
if (installedPreviouslyIndex === -1) {
installedPreviouslyIndex = items.length;
} else if (installedPreviouslyIndex === installedIndex && items.length) {
installedPreviouslyIndex += 1;
}
const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
if (!appliedUpdates.length) {
return null;
}
const appliedChanges: Update['changes'] = { new: [], fixed: [] };
appliedUpdates.forEach((u: Update) => {
if (u.changes) {
appliedChanges.new.push(...u.changes.new);
appliedChanges.fixed.push(...u.changes.fixed);
}
});
const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], {
changes: appliedChanges,
});
if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
mergedUpdate.changes = null;
}
return mergedUpdate;
}
interface AppUpdatedModalContentProps {
onModalClose: () => void;
}
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isPopulated, error, items } = useSelector(
(state: AppState) => state.system.updates
);
const previousVersion = usePrevious(version);
const { onModalClose } = props;
const update = mergeUpdates(items, version, prevVersion);
const handleSeeChangesPress = useCallback(() => {
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
}, []);
useEffect(() => {
dispatch(fetchUpdates());
}, [dispatch]);
useEffect(() => {
if (version !== previousVersion) {
dispatch(fetchUpdates());
}
}, [version, previousVersion, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('AppUpdated')}</ModalHeader>
<ModalBody>
<div>
<InlineMarkdown
data={translate('AppUpdatedVersion', { version })}
blockClassName={styles.version}
/>
</div>
{isPopulated && !error && !!update ? (
<div>
{update.changes ? (
<div className={styles.maintenance}>
{translate('MaintenanceRelease')}
</div>
) : null}
{update.changes ? (
<div>
<div className={styles.changes}>{translate('WhatsNew')}</div>
<UpdateChanges
title={translate('New')}
changes={update.changes.new}
/>
<UpdateChanges
title={translate('Fixed')}
changes={update.changes.fixed}
/>
</div>
) : null}
</div>
) : null}
{!isPopulated && !error ? <LoadingIndicator /> : null}
</ModalBody>
<ModalFooter>
<Button onPress={handleSeeChangesPress}>
{translate('RecentChanges')}
</Button>
<Button kind={kinds.PRIMARY} onPress={onModalClose}>
{translate('Reload')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default AppUpdatedModalContent;

View File

@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchUpdates } from 'Store/Actions/systemActions';
import AppUpdatedModalContent from './AppUpdatedModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.app.version,
(state) => state.app.prevVersion,
(state) => state.system.updates,
(version, prevVersion, updates) => {
const {
isPopulated,
error,
items
} = updates;
return {
version,
prevVersion,
isPopulated,
error,
items
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchUpdates() {
dispatch(fetchUpdates());
},
onSeeChangesPress() {
window.location = `${window.Sonarr.urlBase}/system/updates`;
}
};
}
class AppUpdatedModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchUpdates();
}
componentDidUpdate(prevProps) {
if (prevProps.version !== this.props.version) {
this.props.dispatchFetchUpdates();
}
}
//
// Render
render() {
const {
dispatchFetchUpdates,
...otherProps
} = this.props;
return (
<AppUpdatedModalContent {...otherProps} />
);
}
}
AppUpdatedModalContentConnector.propTypes = {
version: PropTypes.string.isRequired,
dispatchFetchUpdates: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);

View File

@ -1,13 +1,9 @@
import React, { Fragment, ReactNode, useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import themes from 'Styles/Themes'; import themes from 'Styles/Themes';
import AppState from './State/AppState'; import AppState from './State/AppState';
interface ApplyThemeProps {
children: ReactNode;
}
function createThemeSelector() { function createThemeSelector() {
return createSelector( return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme, (state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
@ -17,7 +13,7 @@ function createThemeSelector() {
); );
} }
function ApplyTheme({ children }: ApplyThemeProps) { function ApplyTheme() {
const theme = useSelector(createThemeSelector()); const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => { const updateCSSVariables = useCallback(() => {
@ -31,7 +27,7 @@ function ApplyTheme({ children }: ApplyThemeProps) {
updateCSSVariables(); updateCSSVariables();
}, [updateCSSVariables, theme]); }, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>; return null;
} }
export default ApplyTheme; export default ApplyTheme;

View File

@ -1,5 +1,4 @@
import PropTypes from 'prop-types'; import React, { useCallback } from 'react';
import React from 'react';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
@ -10,36 +9,31 @@ import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './ConnectionLostModal.css'; import styles from './ConnectionLostModal.css';
function ConnectionLostModal(props) { interface ConnectionLostModalProps {
const { isOpen: boolean;
isOpen, }
onModalClose
} = props; function ConnectionLostModal(props: ConnectionLostModalProps) {
const { isOpen } = props;
const handleModalClose = useCallback(() => {
location.reload();
}, []);
return ( return (
<Modal <Modal isOpen={isOpen} onModalClose={handleModalClose}>
isOpen={isOpen} <ModalContent onModalClose={handleModalClose}>
onModalClose={onModalClose} <ModalHeader>{translate('ConnectionLost')}</ModalHeader>
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ConnectionLost')}
</ModalHeader>
<ModalBody> <ModalBody>
<div> <div>{translate('ConnectionLostToBackend')}</div>
{translate('ConnectionLostToBackend')}
</div>
<div className={styles.automatic}> <div className={styles.automatic}>
{translate('ConnectionLostReconnect')} {translate('ConnectionLostReconnect')}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button <Button kind={kinds.PRIMARY} onPress={handleModalClose}>
kind={kinds.PRIMARY}
onPress={onModalClose}
>
{translate('Reload')} {translate('Reload')}
</Button> </Button>
</ModalFooter> </ModalFooter>
@ -48,9 +42,4 @@ function ConnectionLostModal(props) {
); );
} }
ConnectionLostModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ConnectionLostModal; export default ConnectionLostModal;

View File

@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import ConnectionLostModal from './ConnectionLostModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
location.reload();
}
};
}
export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);

View File

@ -1,5 +1,6 @@
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection'; import SortDirection from 'Helpers/Props/SortDirection';
import { FilterBuilderProp } from './AppState'; import { FilterBuilderProp, PropertyFilter } from './AppState';
export interface Error { export interface Error {
responseJSON: { responseJSON: {
@ -18,11 +19,18 @@ export interface AppSectionSaveState {
} }
export interface PagedAppSectionState { export interface PagedAppSectionState {
page: number;
pageSize: number; pageSize: number;
totalPages: number;
totalRecords?: number; totalRecords?: number;
} }
export interface TableAppSectionState {
columns: Column[];
}
export interface AppSectionFilterState<T> { export interface AppSectionFilterState<T> {
selectedFilterKey: string;
filters: PropertyFilter[];
filterBuilderProps: FilterBuilderProp<T>[]; filterBuilderProps: FilterBuilderProp<T>[];
} }
@ -35,6 +43,13 @@ export interface AppSectionSchemaState<T> {
}; };
} }
export interface AppSectionItemSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;
schemaError: Error;
schema: T;
}
export interface AppSectionItemState<T> { export interface AppSectionItemState<T> {
isFetching: boolean; isFetching: boolean;
isPopulated: boolean; isPopulated: boolean;

View File

@ -35,18 +35,21 @@ export interface PropertyFilter {
export interface Filter { export interface Filter {
key: string; key: string;
label: string; label: string;
filers: PropertyFilter[]; filters: PropertyFilter[];
} }
export interface CustomFilter { export interface CustomFilter {
id: number; id: number;
type: string; type: string;
label: string; label: string;
filers: PropertyFilter[]; filters: PropertyFilter[];
} }
export interface AppSectionState { export interface AppSectionState {
isConnected: boolean;
isReconnecting: boolean;
version: string; version: string;
prevVersion?: string;
dimensions: { dimensions: {
isSmallScreen: boolean; isSmallScreen: boolean;
width: number; width: number;
@ -59,6 +62,7 @@ interface AppState {
blocklist: BlocklistAppState; blocklist: BlocklistAppState;
calendar: CalendarAppState; calendar: CalendarAppState;
commands: CommandAppState; commands: CommandAppState;
episodes: EpisodesAppState;
episodeFiles: EpisodeFilesAppState; episodeFiles: EpisodeFilesAppState;
episodesSelection: EpisodesAppState; episodesSelection: EpisodesAppState;
history: HistoryAppState; history: HistoryAppState;

View File

@ -1,8 +1,16 @@
import Blocklist from 'typings/Blocklist'; import Blocklist from 'typings/Blocklist';
import AppSectionState, { AppSectionFilterState } from './AppSectionState'; import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
interface BlocklistAppState interface BlocklistAppState
extends AppSectionState<Blocklist>, extends AppSectionState<Blocklist>,
AppSectionFilterState<Blocklist> {} AppSectionFilterState<Blocklist>,
PagedAppSectionState,
TableAppSectionState {
isRemoving: boolean;
}
export default BlocklistAppState; export default BlocklistAppState;

View File

@ -1,10 +1,14 @@
import AppSectionState, { import AppSectionState, {
AppSectionFilterState, AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import History from 'typings/History'; import History from 'typings/History';
interface HistoryAppState interface HistoryAppState
extends AppSectionState<History>, extends AppSectionState<History>,
AppSectionFilterState<History> {} AppSectionFilterState<History>,
PagedAppSectionState,
TableAppSectionState {}
export default HistoryAppState; export default HistoryAppState;

View File

@ -3,15 +3,29 @@ import AppSectionState, {
AppSectionFilterState, AppSectionFilterState,
AppSectionItemState, AppSectionItemState,
Error, Error,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState'; } from './AppSectionState';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> { export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown; params: unknown;
} }
export interface QueuePagedAppState export interface QueuePagedAppState
extends AppSectionState<Queue>, extends AppSectionState<Queue>,
AppSectionFilterState<Queue> { AppSectionFilterState<Queue>,
PagedAppSectionState,
TableAppSectionState {
isGrabbing: boolean; isGrabbing: boolean;
grabError: Error; grabError: Error;
isRemoving: boolean; isRemoving: boolean;
@ -19,9 +33,12 @@ export interface QueuePagedAppState
} }
interface QueueAppState { interface QueueAppState {
status: AppSectionItemState<Queue>; status: AppSectionItemState<QueueStatus>;
details: QueueDetailsAppState; details: QueueDetailsAppState;
paged: QueuePagedAppState; paged: QueuePagedAppState;
options: {
includeUnknownSeriesItems: boolean;
};
} }
export default QueueAppState; export default QueueAppState;

View File

@ -1,8 +1,8 @@
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionItemSchemaState,
AppSectionItemState, AppSectionItemState,
AppSectionSaveState, AppSectionSaveState,
AppSectionSchemaState,
PagedAppSectionState, PagedAppSectionState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import Language from 'Language/Language'; import Language from 'Language/Language';
@ -20,7 +20,9 @@ import UiSettings from 'typings/Settings/UiSettings';
export interface DownloadClientAppState export interface DownloadClientAppState
extends AppSectionState<DownloadClient>, extends AppSectionState<DownloadClient>,
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState {} AppSectionSaveState {
isTestingAll: boolean;
}
export type GeneralAppState = AppSectionItemState<General>; export type GeneralAppState = AppSectionItemState<General>;
@ -32,7 +34,9 @@ export interface ImportListAppState
export interface IndexerAppState export interface IndexerAppState
extends AppSectionState<Indexer>, extends AppSectionState<Indexer>,
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState {} AppSectionSaveState {
isTestingAll: boolean;
}
export interface NotificationAppState export interface NotificationAppState
extends AppSectionState<Notification>, extends AppSectionState<Notification>,
@ -40,7 +44,7 @@ export interface NotificationAppState
export interface QualityProfilesAppState export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>, extends AppSectionState<QualityProfile>,
AppSectionSchemaState<QualityProfile> {} AppSectionItemSchemaState<QualityProfile> {}
export interface ImportListOptionsSettingsAppState export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>, extends AppSectionItemState<ImportListOptionsSettings>,

View File

@ -1,13 +1,22 @@
import DiskSpace from 'typings/DiskSpace';
import Health from 'typings/Health';
import SystemStatus from 'typings/SystemStatus'; import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import Update from 'typings/Update'; import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState'; import AppSectionState, { AppSectionItemState } from './AppSectionState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>; export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type UpdateAppState = AppSectionState<Update>; export type UpdateAppState = AppSectionState<Update>;
export type TaskAppState = AppSectionState<Task>;
interface SystemAppState { interface SystemAppState {
diskSpace: DiskSpaceAppState;
health: HealthAppState;
updates: UpdateAppState; updates: UpdateAppState;
status: SystemStatusAppState; status: SystemStatusAppState;
tasks: TaskAppState;
} }
export default SystemAppState; export default SystemAppState;

View File

@ -1,5 +1,16 @@
import ModelBase from 'App/ModelBase'; import ModelBase from 'App/ModelBase';
export type CommandStatus =
| 'queued'
| 'started'
| 'completed'
| 'failed'
| 'aborted'
| 'cancelled'
| 'orphaned';
export type CommandResult = 'unknown' | 'successful' | 'unsuccessful';
export interface CommandBody { export interface CommandBody {
sendUpdatesToClient: boolean; sendUpdatesToClient: boolean;
updateScheduledTask: boolean; updateScheduledTask: boolean;
@ -15,6 +26,7 @@ export interface CommandBody {
seriesId?: number; seriesId?: number;
seriesIds?: number[]; seriesIds?: number[];
seasonNumber?: number; seasonNumber?: number;
[key: string]: string | number | boolean | undefined | number[] | undefined;
} }
interface Command extends ModelBase { interface Command extends ModelBase {
@ -23,8 +35,8 @@ interface Command extends ModelBase {
message: string; message: string;
body: CommandBody; body: CommandBody;
priority: string; priority: string;
status: string; status: CommandStatus;
result: string; result: CommandResult;
queued: string; queued: string;
started: string; started: string;
ended: string; ended: string;

View File

@ -64,7 +64,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
<div>{info.componentStack}</div> <div>{info.componentStack}</div>
)} )}
{<div className={styles.version}>Version: {window.Sonarr.version}</div>} <div className={styles.version}>Version: {window.Sonarr.version}</div>
</details> </details>
</div> </div>
); );

View File

@ -13,6 +13,7 @@ import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue'; import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue'; import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
@ -79,6 +80,9 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.QUALITY_PROFILE: case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValueConnector; return QualityProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES: case filterBuilderValueTypes.SERIES:
return SeriesFilterBuilderRowValue; return SeriesFilterBuilderRowValue;

View File

@ -0,0 +1,35 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const seasonsMonitoredStatusList = [
{
id: 'all',
get name() {
return translate('SeasonsMonitoredAll');
}
},
{
id: 'partial',
get name() {
return translate('SeasonsMonitoredPartial');
}
},
{
id: 'none',
get name() {
return translate('SeasonsMonitoredNone');
}
}
];
function SeasonsMonitoredStatusFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={seasonsMonitoredStatusList}
{...props}
/>
);
}
export default SeasonsMonitoredStatusFilterBuilderRowValue;

View File

@ -30,5 +30,6 @@
.label { .label {
composes: label from '~Components/Label.css'; composes: label from '~Components/Label.css';
display: flex;
max-width: 100%; max-width: 100%;
} }

View File

@ -1,8 +1,8 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; import AppUpdatedModal from 'App/AppUpdatedModal';
import ColorImpairedContext from 'App/ColorImpairedContext'; import ColorImpairedContext from 'App/ColorImpairedContext';
import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; import ConnectionLostModal from 'App/ConnectionLostModal';
import SignalRConnector from 'Components/SignalRConnector'; import SignalRConnector from 'Components/SignalRConnector';
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal'; import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
import locationShape from 'Helpers/Props/Shapes/locationShape'; import locationShape from 'Helpers/Props/Shapes/locationShape';
@ -101,12 +101,12 @@ class Page extends Component {
{children} {children}
</div> </div>
<AppUpdatedModalConnector <AppUpdatedModal
isOpen={this.state.isUpdatedModalOpen} isOpen={this.state.isUpdatedModalOpen}
onModalClose={this.onUpdatedModalClose} onModalClose={this.onUpdatedModalClose}
/> />
<ConnectionLostModalConnector <ConnectionLostModal
isOpen={this.state.isConnectionLostModalOpen} isOpen={this.state.isConnectionLostModalOpen}
onModalClose={this.onConnectionLostModalClose} onModalClose={this.onConnectionLostModalClose}
/> />

View File

@ -3,13 +3,13 @@ import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; 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 OverlayScroller from 'Components/Scroller/OverlayScroller';
import Scroller from 'Components/Scroller/Scroller'; import Scroller from 'Components/Scroller/Scroller';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import locationShape from 'Helpers/Props/Shapes/locationShape'; import locationShape from 'Helpers/Props/Shapes/locationShape';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; import HealthStatus from 'System/Status/Health/HealthStatus';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import MessagesConnector from './Messages/MessagesConnector'; import MessagesConnector from './Messages/MessagesConnector';
import PageSidebarItem from './PageSidebarItem'; import PageSidebarItem from './PageSidebarItem';
@ -50,7 +50,7 @@ const links = [
{ {
title: () => translate('Queue'), title: () => translate('Queue'),
to: '/activity/queue', to: '/activity/queue',
statusComponent: QueueStatusConnector statusComponent: QueueStatus
}, },
{ {
title: () => translate('History'), title: () => translate('History'),
@ -147,7 +147,7 @@ const links = [
{ {
title: () => translate('Status'), title: () => translate('Status'),
to: '/system/status', to: '/system/status',
statusComponent: HealthStatusConnector statusComponent: HealthStatus
}, },
{ {
title: () => translate('Tasks'), title: () => translate('Tasks'),

View File

@ -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 (
<Component
className={className}
{...otherProps}
/>
);
}
return (
<Component
className={className}
title={formatDateTime(date, longDateFormat, timeFormat, { includeSeconds, includeRelativeDay: !showRelativeDates })}
{...otherProps}
>
{getRelativeDate({ date, shortDateFormat, showRelativeDates, timeFormat, includeSeconds, includeTime, timeForToday: true })}
</Component>
);
}
}
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;

View File

@ -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 <Component className={className} {...otherProps} />;
}
return (
<Component
className={className}
title={formatDateTime(date, longDateFormat, timeFormat, {
includeSeconds,
includeRelativeDay: !showRelativeDates,
})}
{...otherProps}
>
{getRelativeDate({
date,
shortDateFormat,
showRelativeDates,
timeFormat,
includeSeconds,
includeTime,
timeForToday: true,
})}
</Component>
);
}
export default RelativeDateCell;

View File

@ -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);

View File

@ -2,9 +2,11 @@ import React from 'react';
type PropertyFunction<T> = () => T; type PropertyFunction<T> = () => T;
// TODO: Convert to generic so `name` can be a type
interface Column { interface Column {
name: string; name: string;
label: string | PropertyFunction<string> | React.ReactNode; label: string | PropertyFunction<string> | React.ReactNode;
className?: string;
columnLabel?: string; columnLabel?: string;
isSortable?: boolean; isSortable?: boolean;
isVisible: boolean; isVisible: boolean;

View File

@ -0,0 +1,54 @@
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
interface PagingOptions {
page: number;
totalPages: number;
gotoPage: ({ page }: { page: number }) => void;
}
function usePaging(options: PagingOptions) {
const { page, totalPages, gotoPage } = options;
const dispatch = useDispatch();
const handleFirstPagePress = useCallback(() => {
dispatch(gotoPage({ page: 1 }));
}, [dispatch, gotoPage]);
const handlePreviousPagePress = useCallback(() => {
dispatch(gotoPage({ page: Math.max(page - 1, 1) }));
}, [page, dispatch, gotoPage]);
const handleNextPagePress = useCallback(() => {
dispatch(gotoPage({ page: Math.min(page + 1, totalPages) }));
}, [page, totalPages, dispatch, gotoPage]);
const handleLastPagePress = useCallback(() => {
dispatch(gotoPage({ page: totalPages }));
}, [totalPages, dispatch, gotoPage]);
const handlePageSelect = useCallback(
(page: number) => {
dispatch(gotoPage({ page }));
},
[dispatch, gotoPage]
);
return useMemo(() => {
return {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
};
}, [
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
]);
}
export default usePaging;

View File

@ -1,7 +1,3 @@
enum DownloadProtocol { type DownloadProtocol = 'usenet' | 'torrent' | 'unknown';
Unknown = 'unknown',
Usenet = 'usenet',
Torrent = 'torrent',
}
export default DownloadProtocol; export default DownloadProtocol;

View File

@ -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 (
<span>
{
hasSceneInformation ?
<Popover
anchor={
<span>
{
showSeasonNumber && seasonNumber != null &&
<Fragment>
{seasonNumber}x
</Fragment>
}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
{
seriesType === 'anime' && !!absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
}
</span>
}
title={translate('SceneInformation')}
body={
<SceneInfo
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
sceneSeasonNumber={sceneSeasonNumber}
sceneEpisodeNumber={sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
alternateTitles={alternateTitles}
seriesType={seriesType}
/>
}
position={tooltipPositions.RIGHT}
/> :
<span>
{
showSeasonNumber && seasonNumber != null &&
<Fragment>
{seasonNumber}x
</Fragment>
}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
{
seriesType === 'anime' && !!absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
}
</span>
}
{
warningMessage ?
<Icon
className={styles.warning}
name={icons.WARNING}
kind={kinds.WARNING}
title={warningMessage}
/> :
null
}
</span>
);
}
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;

View File

@ -0,0 +1,136 @@
import React 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 (
<span>
{hasSceneInformation ? (
<Popover
anchor={
<span>
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
{seriesType === 'anime' && !!absoluteEpisodeNumber && (
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
)}
</span>
}
title={translate('SceneInformation')}
body={
<SceneInfo
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
sceneSeasonNumber={sceneSeasonNumber}
sceneEpisodeNumber={sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
alternateTitles={alternateTitles}
seriesType={seriesType}
/>
}
position={tooltipPositions.RIGHT}
/>
) : (
<span>
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
{seriesType === 'anime' && !!absoluteEpisodeNumber && (
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
)}
</span>
)}
{warningMessage ? (
<Icon
className={styles.warning}
name={icons.WARNING}
kind={kinds.WARNING}
title={warningMessage}
/>
) : null}
</span>
);
}
export default EpisodeNumber;

View File

@ -1,11 +1,15 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Label from 'Components/Label'; import Label from 'Components/Label';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { QualityModel } from 'Quality/Quality';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
function getTooltip(title, quality, size) { function getTooltip(
title: string,
quality: QualityModel,
size: number | undefined
) {
if (!title) { if (!title) {
return; return;
} }
@ -27,7 +31,11 @@ function getTooltip(title, quality, size) {
return title; return title;
} }
function revisionLabel(className, quality, showRevision) { function revisionLabel(
className: string | undefined,
quality: QualityModel,
showRevision: boolean
) {
if (!showRevision) { if (!showRevision) {
return; return;
} }
@ -55,16 +63,27 @@ function revisionLabel(className, quality, showRevision) {
</Label> </Label>
); );
} }
return null;
} }
function EpisodeQuality(props) { interface EpisodeQualityProps {
className?: string;
title?: string;
quality: QualityModel;
size?: number;
isCutoffNotMet?: boolean;
showRevision?: boolean;
}
function EpisodeQuality(props: EpisodeQualityProps) {
const { const {
className, className,
title, title = '',
quality, quality,
size, size,
isCutoffNotMet, isCutoffNotMet,
showRevision showRevision = false,
} = props; } = props;
if (!quality) { if (!quality) {
@ -79,23 +98,10 @@ function EpisodeQuality(props) {
title={getTooltip(title, quality, size)} title={getTooltip(title, quality, size)}
> >
{quality.quality.name} {quality.quality.name}
</Label>{revisionLabel(className, quality, showRevision)} </Label>
{revisionLabel(className, quality, showRevision)}
</span> </span>
); );
} }
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; export default EpisodeQuality;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
@ -6,8 +5,12 @@ import FinaleType from './FinaleType';
import styles from './EpisodeTitleLink.css'; import styles from './EpisodeTitleLink.css';
interface EpisodeTitleLinkProps { interface EpisodeTitleLinkProps {
episodeId: number;
seriesId: number;
episodeEntity: string;
episodeTitle: string; episodeTitle: string;
finaleType?: string; finaleType?: string;
showOpenSeriesButton: boolean;
} }
function EpisodeTitleLink(props: EpisodeTitleLinkProps) { function EpisodeTitleLink(props: EpisodeTitleLinkProps) {
@ -38,9 +41,4 @@ function EpisodeTitleLink(props: EpisodeTitleLinkProps) {
); );
} }
EpisodeTitleLink.propTypes = {
episodeTitle: PropTypes.string.isRequired,
finaleType: PropTypes.string,
};
export default EpisodeTitleLink; export default EpisodeTitleLink;

View File

@ -1,11 +1,11 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; 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 HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal'; 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 TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
@ -109,7 +109,7 @@ class EpisodeHistoryRow extends Component {
{formatCustomFormatScore(customFormatScore, customFormats.length)} {formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell> </TableRowCell>
<RelativeDateCellConnector <RelativeDateCell
date={date} date={date}
includeSeconds={true} includeSeconds={true}
includeTime={true} includeTime={true}
@ -124,7 +124,7 @@ class EpisodeHistoryRow extends Component {
} }
title={getTitle(eventType)} title={getTitle(eventType)}
body={ body={
<HistoryDetailsConnector <HistoryDetails
eventType={eventType} eventType={eventType}
sourceTitle={sourceTitle} sourceTitle={sourceTitle}
data={data} data={data}

View File

@ -1,32 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import EpisodeNumber from './EpisodeNumber';
function SeasonEpisodeNumber(props) {
const {
airDate,
seriesType,
...otherProps
} = props;
if (seriesType === 'daily' && airDate) {
return (
<span>{airDate}</span>
);
}
return (
<EpisodeNumber
seriesType={seriesType}
showSeasonNumber={true}
{...otherProps}
/>
);
}
SeasonEpisodeNumber.propTypes = {
airDate: PropTypes.string,
seriesType: PropTypes.string
};
export default SeasonEpisodeNumber;

View File

@ -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 <span>{airDate}</span>;
}
return (
<EpisodeNumber
seriesType={seriesType}
showSeasonNumber={true}
{...otherProps}
/>
);
}
export default SeasonEpisodeNumber;

View File

@ -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;

View File

@ -0,0 +1,47 @@
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 | undefined,
episodeEntity: EpisodeEntities
) {
let selector = createEpisodeSelector;
switch (episodeEntity) {
case 'calendar':
selector = createCalendarEpisodeSelector;
break;
default:
break;
}
return useSelector(selector(episodeId));
}
export default useEpisode;

View File

@ -0,0 +1,9 @@
import { useHistory } from 'react-router-dom';
function useCurrentPage() {
const history = useHistory();
return history.action === 'POP';
}
export default useCurrentPage;

View File

@ -3,15 +3,15 @@ import { useCallback, useState } from 'react';
export default function useModalOpenState( export default function useModalOpenState(
initialState: boolean initialState: boolean
): [boolean, () => void, () => void] { ): [boolean, () => void, () => void] {
const [isOpen, setOpen] = useState(initialState); const [isOpen, setIsOpen] = useState(initialState);
const setModalOpen = useCallback(() => { const setModalOpen = useCallback(() => {
setOpen(true); setIsOpen(true);
}, [setOpen]); }, [setIsOpen]);
const setModalClosed = useCallback(() => { const setModalClosed = useCallback(() => {
setOpen(false); setIsOpen(false);
}, [setOpen]); }, [setIsOpen]);
return [isOpen, setModalOpen, setModalClosed]; return [isOpen, setModalOpen, setModalClosed];
} }

View File

@ -1,8 +1,3 @@
enum TooltipPosition { type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
Top = 'top',
Right = 'right',
Bottom = 'bottom',
Left = 'left',
}
export default TooltipPosition; export default TooltipPosition;

View File

@ -8,6 +8,7 @@ export const LANGUAGE = 'language';
export const PROTOCOL = 'protocol'; export const PROTOCOL = 'protocol';
export const QUALITY = 'quality'; export const QUALITY = 'quality';
export const QUALITY_PROFILE = 'qualityProfile'; export const QUALITY_PROFILE = 'qualityProfile';
export const SEASONS_MONITORED_STATUS = 'seasonsMonitoredStatus';
export const SERIES = 'series'; export const SERIES = 'series';
export const SERIES_STATUS = 'seriesStatus'; export const SERIES_STATUS = 'seriesStatus';
export const SERIES_TYPES = 'seriesType'; export const SERIES_TYPES = 'seriesType';

View File

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton'; 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 TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton'; import TableRowButton from 'Components/Table/TableRowButton';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
@ -41,7 +41,7 @@ class RecentFolderRow extends Component {
<TableRowButton onPress={this.onPress}> <TableRowButton onPress={this.onPress}>
<TableRowCell>{folder}</TableRowCell> <TableRowCell>{folder}</TableRowCell>
<RelativeDateCellConnector date={lastUsed} /> <RelativeDateCell date={lastUsed} />
<TableRowCell className={styles.actions}> <TableRowCell className={styles.actions}>
<IconButton <IconButton

View File

@ -18,12 +18,17 @@
.leftButtons, .leftButtons,
.rightButtons { .rightButtons {
display: flex; display: flex;
flex: 1 0 50%;
flex-wrap: wrap; flex-wrap: wrap;
min-width: 0;
}
.leftButtons {
flex: 0 1 auto;
} }
.rightButtons { .rightButtons {
justify-content: flex-end; justify-content: flex-end;
flex: 1 1 50%;
} }
.deleteButton { .deleteButton {
@ -37,6 +42,7 @@
composes: select from '~Components/Form/SelectInput.css'; composes: select from '~Components/Form/SelectInput.css';
margin-right: 10px; margin-right: 10px;
max-width: 100%;
width: auto; width: auto;
} }
@ -49,10 +55,12 @@
.leftButtons, .leftButtons,
.rightButtons { .rightButtons {
flex-direction: column; flex-direction: column;
gap: 3px;
} }
.leftButtons { .leftButtons {
align-items: flex-start; align-items: flex-start;
max-width: fit-content;
} }
.rightButtons { .rightButtons {

View File

@ -857,7 +857,7 @@ function InteractiveImportModalContent(
<MenuContent> <MenuContent>
<SelectedMenuItem <SelectedMenuItem
name={'all'} name="all"
isSelected={!filterExistingFiles} isSelected={!filterExistingFiles}
onPress={onFilterExistingFilesChange} onPress={onFilterExistingFilesChange}
> >
@ -865,7 +865,7 @@ function InteractiveImportModalContent(
</SelectedMenuItem> </SelectedMenuItem>
<SelectedMenuItem <SelectedMenuItem
name={'new'} name="new"
isSelected={filterExistingFiles} isSelected={filterExistingFiles}
onPress={onFilterExistingFilesChange} onPress={onFilterExistingFilesChange}
> >
@ -945,7 +945,7 @@ function InteractiveImportModalContent(
<SelectInput <SelectInput
className={styles.bulkSelect} className={styles.bulkSelect}
name="select" name="select"
value={'select'} value="select"
values={bulkSelectOptions} values={bulkSelectOptions}
isDisabled={!selectedIds.length} isDisabled={!selectedIds.length}
onChange={onSelectModalSelect} onChange={onSelectModalSelect}

View File

@ -17,7 +17,7 @@ function SelectLanguageModal(props: SelectLanguageModalProps) {
props; props;
return ( return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}> <Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<SelectLanguageModalContent <SelectLanguageModalContent
languageIds={languageIds} languageIds={languageIds}
modalTitle={modalTitle} modalTitle={modalTitle}

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Error } from 'App/State/AppSectionState';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import Form from 'Components/Form/Form'; import Form from 'Components/Form/Form';
@ -21,21 +20,14 @@ import { CheckInputChanged } from 'typings/inputs';
import getQualities from 'Utilities/Quality/getQualities'; import getQualities from 'Utilities/Quality/getQualities';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
interface QualitySchemaState {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: Quality[];
}
function createQualitySchemaSelector() { function createQualitySchemaSelector() {
return createSelector( return createSelector(
(state: AppState) => state.settings.qualityProfiles, (state: AppState) => state.settings.qualityProfiles,
(qualityProfiles): QualitySchemaState => { (qualityProfiles) => {
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } = const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
qualityProfiles; qualityProfiles;
const items = getQualities(schema.items) as Quality[]; const items = getQualities(schema.items);
return { return {
isFetching: isSchemaFetching, isFetching: isSchemaFetching,

View File

@ -64,19 +64,20 @@ interface RowItemData {
onSeriesSelect(seriesId: number): void; onSeriesSelect(seriesId: number): void;
} }
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({ function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
index,
style,
data,
}) => {
const { items, columns, onSeriesSelect } = data; const { items, columns, onSeriesSelect } = data;
const series = index >= items.length ? null : items[index];
if (index >= items.length) { const handlePress = useCallback(() => {
if (series?.id) {
onSeriesSelect(series.id);
}
}, [series?.id, onSeriesSelect]);
if (series == null) {
return null; return null;
} }
const series = items[index];
return ( return (
<VirtualTableRowButton <VirtualTableRowButton
style={{ style={{
@ -84,7 +85,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
justifyContent: 'space-between', justifyContent: 'space-between',
...style, ...style,
}} }}
onPress={() => onSeriesSelect(series.id)} onPress={handlePress}
> >
<SelectSeriesRow <SelectSeriesRow
key={series.id} key={series.id}
@ -98,7 +99,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
/> />
</VirtualTableRowButton> </VirtualTableRowButton>
); );
}; }
function SelectSeriesModalContent(props: SelectSeriesModalContentProps) { function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
const { modalTitle, onSeriesSelect, onModalClose } = props; const { modalTitle, onSeriesSelect, onModalClose } = props;
@ -197,9 +198,9 @@ function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
/> />
<Scroller <Scroller
ref={scrollerRef}
className={styles.scroller} className={styles.scroller}
autoFocus={false} autoFocus={false}
ref={scrollerRef}
> >
<SelectSeriesModalTableHeader columns={columns} /> <SelectSeriesModalTableHeader columns={columns} />
<List<RowItemData> <List<RowItemData>

View File

@ -17,7 +17,7 @@ function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
props; props;
return ( return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}> <Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<SelectDownloadClientModalContent <SelectDownloadClientModalContent
protocol={protocol} protocol={protocol}
modalTitle={modalTitle} modalTitle={modalTitle}

View File

@ -1,4 +1,4 @@
import React, { Fragment, useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import ParseModal from 'Parse/ParseModal'; import ParseModal from 'Parse/ParseModal';
@ -16,7 +16,7 @@ function ParseToolbarButton() {
}, [setIsParseModalOpen]); }, [setIsParseModalOpen]);
return ( return (
<Fragment> <>
<PageToolbarButton <PageToolbarButton
label={translate('TestParsing')} label={translate('TestParsing')}
iconName={icons.PARSE} iconName={icons.PARSE}
@ -24,7 +24,7 @@ function ParseToolbarButton() {
/> />
<ParseModal isOpen={isParseModalOpen} onModalClose={onParseModalClose} /> <ParseModal isOpen={isParseModalOpen} onModalClose={onParseModalClose} />
</Fragment> </>
); );
} }

Some files were not shown because too many files have changed in this diff Show More